diff --git a/.vscode/settings.json b/.vscode/settings.json index 6794bb2c30..8035763ad8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -268,6 +268,8 @@ "install/bitbots_msgs/lib/python3.12/site-packages", "build/bitbots_ball_filter", "install/bitbots_ball_filter/lib/python3.12/site-packages", + "build/better_launch", + "install/better_launch/lib/python3.12/site-packages", ".pixi/envs/default/lib/python/site-packages", ".pixi/envs/default/lib/python3.12/site-packages", ], @@ -304,6 +306,8 @@ "install/bitbots_msgs/lib/python3.12/site-packages", "build/bitbots_ball_filter", "install/bitbots_ball_filter/lib/python3.12/site-packages", + "build/better_launch", + "install/better_launch/lib/python3.12/site-packages", ".pixi/envs/default/lib/python/site-packages", ".pixi/envs/default/lib/python3.12/site-packages", ], diff --git a/pixi.lock b/pixi.lock index 4936a39e6f..79eab2f6f4 100644 --- a/pixi.lock +++ b/pixi.lock @@ -381,6 +381,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/sdformat14-python-14.8.0-py312h22a3d64_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/sdl2-2.32.56-h54a6638_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/sdl3-3.4.16-h5330f5c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/setproctitle-1.3.7-py312h1b36aeb_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/shaderc-2025.5-h718be3e_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/sip-6.10.0-py312h1289d80_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda @@ -500,6 +501,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/distro-1.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/dnspython-2.8.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/docstring_parser-0.18.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/docutils-0.21.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/empy-4.2.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/etils-1.13.0-pyhd8ed1ab_0.conda @@ -555,6 +557,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notify2-0.3.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/nvidia-ml-py-13.610.43-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/osrf_pycommon-0.2.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/paramiko-4.0.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.7-pyhcf101f3_0.conda @@ -567,6 +570,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/ply-3.11-pyhd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.2-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/prompt_toolkit-3.0.53-hd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pybind11-3.0.4-pyh293190f_0.conda @@ -1376,6 +1380,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/sdformat14-python-14.8.0-py312he22bb7a_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/sdl2-2.32.56-h7ac5ae9_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/sdl3-3.4.16-hab42d0a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/setproctitle-1.3.7-py312h085bef5_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/shaderc-2025.5-hfeb5c2c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/sip-6.16.1-py312h241b232_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/snappy-1.2.2-he774c54_1.conda @@ -1495,6 +1500,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/distro-1.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/dnspython-2.8.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/docstring_parser-0.18.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/docutils-0.21.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/empy-4.2.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/etils-1.13.0-pyhd8ed1ab_0.conda @@ -1551,6 +1557,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notify2-0.3.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/nvidia-ml-py-13.610.43-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/osrf_pycommon-0.2.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/paramiko-4.0.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.7-pyhcf101f3_0.conda @@ -1563,6 +1570,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/ply-3.11-pyhd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.2-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/prompt_toolkit-3.0.53-hd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pybind11-3.0.4-pyh293190f_0.conda @@ -2513,6 +2521,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/sdformat14-python-14.8.0-py312h22a3d64_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/sdl2-2.32.56-h54a6638_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/sdl3-3.4.16-h5330f5c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/setproctitle-1.3.7-py312h1b36aeb_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/shaderc-2025.5-h718be3e_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/sip-6.10.0-py312h1289d80_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda @@ -2632,6 +2641,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/distro-1.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/dnspython-2.8.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/docstring_parser-0.18.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/docutils-0.21.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/empy-4.2.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/etils-1.13.0-pyhd8ed1ab_0.conda @@ -2687,6 +2697,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notify2-0.3.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/nvidia-ml-py-13.610.43-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/osrf_pycommon-0.2.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/paramiko-4.0.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.7-pyhcf101f3_0.conda @@ -2699,6 +2710,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/ply-3.11-pyhd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.2-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/prompt_toolkit-3.0.53-hd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pybind11-3.0.4-pyh293190f_0.conda @@ -3512,6 +3524,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/sdformat14-python-14.8.0-py312he22bb7a_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/sdl2-2.32.56-h7ac5ae9_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/sdl3-3.4.16-hab42d0a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/setproctitle-1.3.7-py312h085bef5_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/shaderc-2025.5-hfeb5c2c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/sip-6.16.1-py312h241b232_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/snappy-1.2.2-he774c54_1.conda @@ -3631,6 +3644,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/distro-1.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/dnspython-2.8.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/docstring_parser-0.18.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/docutils-0.21.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/empy-4.2.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/etils-1.13.0-pyhd8ed1ab_0.conda @@ -3687,6 +3701,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notify2-0.3.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/nvidia-ml-py-13.610.43-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/osrf_pycommon-0.2.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/paramiko-4.0.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.7-pyhcf101f3_0.conda @@ -3699,6 +3714,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/ply-3.11-pyhd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.2-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/prompt_toolkit-3.0.53-hd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pybind11-3.0.4-pyh293190f_0.conda @@ -4354,7 +4370,7 @@ packages: - zstd >=1.5.7,<1.6.0a0 license: BSD-3-Clause AND MIT AND EPL-2.0 purls: - - pkg:pypi/backports-zstd?source=compressed-mapping + - pkg:pypi/backports-zstd?source=hash-mapping run_exports: {} size: 241113 timestamp: 1788491295568 @@ -4371,7 +4387,7 @@ packages: license: Apache-2.0 license_family: APACHE purls: - - pkg:pypi/bcrypt?source=compressed-mapping + - pkg:pypi/bcrypt?source=hash-mapping run_exports: {} size: 283491 timestamp: 1788505779281 @@ -6254,7 +6270,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/kiwisolver?source=compressed-mapping + - pkg:pypi/kiwisolver?source=hash-mapping run_exports: {} size: 75215 timestamp: 1788505643992 @@ -10520,6 +10536,21 @@ packages: - sdl3 >=3.4.16,<4.0a0 size: 2151582 timestamp: 1788377993602 +- conda: https://conda.anaconda.org/conda-forge/linux-64/setproctitle-1.3.7-py312h1b36aeb_1.conda + sha256: 73712b73179802177b09052fbbfb12bac7a6316ebe3a47e6fd8ae4e61af68d7c + md5: 77ac284f5d0c656febc5327787167074 + depends: + - python + - __glibc >=2.17,<3.0.a0 + - libgcc >=15 + - python_abi 3.12.* *_cp312 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/setproctitle?source=hash-mapping + run_exports: {} + size: 23662 + timestamp: 1788523121397 - conda: https://conda.anaconda.org/conda-forge/linux-64/shaderc-2025.5-h718be3e_1.conda sha256: 0c2d6f24ee2b614ee1da4d7d99cc9944ea1ace65455a47d48d8c1f726317168a md5: 8dc8dda113c4c568256bdd486b6e842e @@ -17615,6 +17646,21 @@ packages: - sdl3 >=3.4.16,<4.0a0 size: 2184437 timestamp: 1788378021642 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/setproctitle-1.3.7-py312h085bef5_1.conda + sha256: d10a2783ef07703f2cf9ff4f26aa6ab891ddde847db8e2c481fa6d8fe08b4eeb + md5: 8b7a1712183bbf3dc33a6a78ac2c7722 + depends: + - python + - libgcc >=15 + - python 3.12.* *_cpython + - python_abi 3.12.* *_cp312 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/setproctitle?source=hash-mapping + run_exports: {} + size: 27918 + timestamp: 1788523140667 - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/shaderc-2025.5-hfeb5c2c_1.conda sha256: bf3f47847832e33acbcb7a1aba948f3b574979ad2a91f2ebdc9fc685c09433db md5: 8268bdcd82d8f9abcb7f0fd6a9568ba4 @@ -19372,6 +19418,18 @@ packages: run_exports: {} size: 196500 timestamp: 1757292856922 +- conda: https://conda.anaconda.org/conda-forge/noarch/docstring_parser-0.18.0-pyhd8ed1ab_0.conda + sha256: 0994f88d4257dbfcbf67f10c886d84a531e13e6d36dee3a77ddcca6ffc37d7ca + md5: b0c7c29a60c82d57c5b4c4c38f0642c8 + depends: + - python >=3.10 + license: MIT + license_family: MIT + purls: + - pkg:pypi/docstring-parser?source=hash-mapping + run_exports: {} + size: 23903 + timestamp: 1778609891474 - conda: https://conda.anaconda.org/conda-forge/noarch/docutils-0.21.2-pyhd8ed1ab_1.conda sha256: fa5966bb1718bbf6967a85075e30e4547901410cc7cb7b16daf68942e9a94823 md5: 24c1ca34138ee57de72a943237cde4cc @@ -20151,6 +20209,18 @@ packages: run_exports: {} size: 50107 timestamp: 1780355713540 +- conda: https://conda.anaconda.org/conda-forge/noarch/osrf_pycommon-0.2.1-pyhd8ed1ab_0.tar.bz2 + sha256: 4c0421605528a29342f10f81c2739cf1a395ce1aee820c69db576c02f1925943 + md5: 990a69a331bfd88f9c8b95a725afc40a + depends: + - python >=3.6 + license: Apache-2.0 + license_family: Apache + purls: + - pkg:pypi/osrf-pycommon?source=hash-mapping + run_exports: {} + size: 32902 + timestamp: 1626759108531 - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda sha256: c432626b16768b8dab228bfb706f7060c2d462a21c516d240f68f2f902b5a044 md5: 936687ed80f295a1f5dbcf8bd34c252c @@ -20312,6 +20382,17 @@ packages: run_exports: {} size: 276081 timestamp: 1785160613307 +- conda: https://conda.anaconda.org/conda-forge/noarch/prompt_toolkit-3.0.53-hd8ed1ab_0.conda + sha256: 59628c765189e99ca5d3c51f0758a325bc020dfafb8fef6068045595aaae1baf + md5: 4c7171dde29a2f2b1dac681c1154a291 + depends: + - prompt-toolkit >=3.0.53,<3.0.54.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: {} + size: 7083 + timestamp: 1785160614678 - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda sha256: a7713dfe30faf17508ec359e0bc7e0983f5d94682492469bd462cdaae9c64d83 md5: 7d9daffbb8d8e0af0f769dbbcd173a54 diff --git a/pixi.toml b/pixi.toml index 795e607a50..17c52e5eed 100644 --- a/pixi.toml +++ b/pixi.toml @@ -56,10 +56,12 @@ alsa-plugins = ">=1.2.12,<2" beartype = ">=0.22.6,<0.23" breathe = ">=4.36.0,<5" cbor2 = ">=5.9.0,<6" +click = ">=8.4.2,<9" # Required by better_launch (src/lib/better_launch) cmake = "<3.30" # Constraint to avoid issues with deprecated features in newer cmake versions colorama = ">=0.4.6,<0.5" compilers = ">=1.11.0,<2" construct = ">=2.10.70,<3" +docstring_parser = ">=0.18.0,<0.19" # Required by better_launch (src/lib/better_launch) eigen = ">=3.4.0,<4" fabric = ">=3.2.2,<4" flask = ">=3.1.2,<4" @@ -82,10 +84,12 @@ numpy = ">=1.26.4,<2" nvidia-ml-py = ">=13.595.45,<14" opencv = ">=4.11.0,<5" openssh = ">=10.5p1,<11" +osrf_pycommon = ">=0.2.1,<0.3" # Required by better_launch (src/lib/better_launch) paramiko = ">=4.0.0,<5" pkg-config = ">=0.29.2,<0.30" playsound = ">=1.3.0,<2" portaudio = ">=19.7.0,<20" +prompt_toolkit = ">=3.0.52,<4" # Required by better_launch (src/lib/better_launch) protobuf = ">=6.31.1,<7" psutil = ">=7.1.3,<8" pthread-stubs = ">=0.4,<0.5" @@ -100,6 +104,7 @@ rospkg = ">=1.6.0,<2" rsync = ">=3.4.3,<4" ruff = ">=0.14.6,<0.15" scipy = ">=1.17.1,<2" +setproctitle = ">=1.3.7,<2" # Required by better_launch (src/lib/better_launch) simpleeval = ">=1.0.3,<2" sphinx-rtd-theme = ">=3.0.2,<4" sysroot_linux-64 = ">=2.28,<3" diff --git a/scripts/README.md b/scripts/README.md index a4a8bf2ecc..b92a43212c 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -2,6 +2,8 @@ This directory contains scripts that are very useful for development, testing and deployment. +The [offline launch comparison tool](launch_equivalence/README.md) checks the ROS launch to better-launch migration without starting robot processes. + This tool is also callable via `pixi run deploy `. ## `deploy_robots.py` diff --git a/scripts/deploy/tasks/launch.py b/scripts/deploy/tasks/launch.py index a4334d3767..808ec01257 100644 --- a/scripts/deploy/tasks/launch.py +++ b/scripts/deploy/tasks/launch.py @@ -152,7 +152,7 @@ def _check_tmux_session_already_running(self, connections: Group) -> GroupResult def _launch_teamplayer(self, connections: Group) -> GroupResult: print_debug("Launching teamplayer") # Create tmux session - cmd = f"tmux new-session -d -s {self._tmux_session_name} && tmux send-keys -t {self._tmux_session_name} 'cd {self._remote_workspace} && pixi run --environment robot ros2 launch bitbots_bringup teamplayer.launch record:=true tts:=false' Enter" + cmd = f"tmux new-session -d -s {self._tmux_session_name} && tmux send-keys -t {self._tmux_session_name} 'cd {self._remote_workspace} && pixi run --environment robot bl bitbots_bringup teamplayer.launch.py --record true --tts false' Enter" print_debug(f"Calling '{cmd}'") try: diff --git a/scripts/launch_equivalence/README.md b/scripts/launch_equivalence/README.md new file mode 100644 index 0000000000..43bfaa2582 --- /dev/null +++ b/scripts/launch_equivalence/README.md @@ -0,0 +1,112 @@ +# Offline launch comparison + +This disposable migration checker compares the resolved instructions produced by ROS launch and better-launch. Run it from the repository through the default Pixi environment. It reads Git snapshots, so uncommitted launch edits are excluded. No workspace build or robot connection is required; the Pixi launch libraries and the better-launch source in the migrated revision are required. + +Start by inspecting the matrix: + +```sh +pixi run -e default python scripts/launch_equivalence/verify.py \ + --base --head --plan +``` + +Check whether the current host permits the required containment before running experiments: + +```sh +pixi run -e default python scripts/launch_equivalence/verify.py --check-sandbox +``` + +This checks Landlock and installs the seccomp filter in a disposable subprocess without importing launch files. A failed check reports the detected ABI or syscall error. Older kernels, disabled Landlock, and outer container policies can prevent containment. Use a host with the required Landlock support enabled and its syscalls permitted; there is no unsafe bypass. The check covers containment only, not launch dependencies or resources. + +Use the entrypoint identifiers printed in that plan to select a smaller comparison: + +```sh +pixi run -e default python scripts/launch_equivalence/verify.py \ + --base --head \ + --entry --domains --output +``` + +`--defaults-only` checks entrypoints with all arguments omitted. It is useful for finding missing resources before running a matrix, and is explicitly recorded as limited coverage. Without it, boolean arguments receive omitted, false and true inputs. Other arguments are tested only with omission until their domains are supplied. The plan lists these arguments under `default_only_arguments`. Include-only arguments and arguments constructed dynamically may require explicit entries in the domains file. There is no claim to enumerate arbitrary strings, file contents, integers, environments, or runtime events. + +`--max-cases` refuses an oversized matrix before evaluation; it never truncates a run. `--timeout` bounds each worker response. Select entrypoints or supply narrower domains when needed. Unknown entrypoint selectors and malformed domains fail instead of yielding an empty success. + +Choose a fresh output directory for each run. Nonempty report directories are not overwritten. + +## Input domains + +The domains file is JSON. Keys under `arguments` are entrypoint identifiers; values map launch argument names to lists of CLI strings or `null` for omission. Environment profiles map variable names to strings, or to `null` to unset them. Profiles are crossed with argument combinations. + +```json +{ + "arguments": { + "bitbots_bringup/teamplayer.launch.py": { + "sim": [null, "false", "true"], + "fieldname": [null, ""] + }, + "bitbots_bringup/mujoco_simulation.launch.py": { + "num_robots": [""], + "robot_type": [""] + } + }, + "environments": { + "unset_robot": { + "ROBOT_NAME": null, + "ROBOCUP_ROBOT_ID": null, + "ROS_DOMAIN_ID": null + }, + "selected_robot": { + "ROBOT_NAME": "", + "ROS_DOMAIN_ID": "" + } + } +} +``` + +Replace placeholders with values appropriate for the resources being compared. For forwarded string switches, supply the empty string and boolean spellings as separate cases. The native decorator and include implementations perform the conversions; the checker does not coerce an included string into a boolean. + +The inherited Pixi environment is shared by both sides. Robot identity and ROS domain variables are unset unless supplied by a profile. Reports record process environment changes relative to that common environment, rather than copying unrelated host variables. Use the same Pixi lockfile and profiles when reproducing a result. + +## Reduction and caching + +The checker merges omission with an explicit boolean default only when the old entrypoint has a literal, unconditional XML declaration before any actions and the new function has the identical literal boolean default. A conservative source check excludes entrypoints with raw CLI introspection or unfamiliar imports. ROS receives arguments through an include action, and Click binds the corresponding typed value. Conditional defaults, differing defaults, and non-boolean inputs are kept separate. Every reduction and both the original and reduced assignment counts are recorded in the plan. These reductions apply to the inspected argument-binding patterns; arbitrary Python introspection through external helpers is outside the supported model. + +It exhaustively enumerates the resulting finite Cartesian product. It does not use pairwise sampling or stop testing an output after finding a discrepancy. Identical request results have a bounded cache. Process normalization is cached by the entire resolved command, environment delta, working directory, process policy, and parameter file contents. This reuses repeated leaf results without assuming argument or subtree independence. A generated temporary parameter filename does not invalidate a content-equivalent cache entry; changing the file contents does. + +The tool does **not** yet perform general dependency-based subtree pruning. It still evaluates the orchestration for each reduced assignment. In particular, the process-cache counters measure reused normalization work, not skipped whole launch executions. Adding subtree pruning would require proving which shared state and effects a subtree reads and writes on both sides. Repeated observations alone are insufficient proof. + +## What is compared + +The ROS adapter uses native parsing, conditions, scoped launch configurations, substitutions, node expansion, and process preparation, stopping before process execution. The better-launch adapter uses the source decorator, include and grouping implementation, and command construction through the process-spawn boundary. Regular ROS files included by better-launch use the ROS adapter. These hooks intentionally depend on private APIs: incompatible versions produce unresolved cases. + +Manifests retain executable identity, node names and namespaces, ordered remaps and application arguments, typed parameters and precedence, environment changes, working directories, shell mode, output destinations, respawn settings, lifecycle targets, and launch-defined order. Raw commands and parameter-source paths are retained for diagnosis but are excluded from semantic comparison. Missing native node names remain unspecified; the checker does not guess executable defaults from the migrated file. Node-specific parameter selectors without a known name are unresolved. + +ROS component load requests are prepared without contacting their container. Wall-clock timestamps are fixed for reproducibility. Simple timers are evaluated without waiting, retaining their delay and cancellation policy. Nested launcher subprocesses are captured as boundaries and recursively evaluated with their arguments and environment. Generated package files are captured and removed between cases. Xacro and the welcome text read are evaluated in-process; other command substitutions are unresolved. + +Unsupported actions, lifecycle event transitions, exit callbacks, nested timers, missing Python dependencies, missing resources, ambiguous parameter selectors and preparation failures prevent a clean equivalence verdict. A case may contain both useful differences and unresolved parts. Matching failures are never reported as equivalent. Launch logging and runtime scheduling are not a proof of robot behavior; the comparison covers the instructions described above. + +Executable paths are symbolic and package resources come from the respective snapshot, with Pixi resources as the fallback for external packages. Launcher infrastructure uses the installed Pixi version. Source resources are exposed in temporary package directories; the checker does not validate that the package build would install all those files. Generated resources absent from the snapshots remain blockers rather than fabricated data. + +## Safety + +Launch evaluation runs in dedicated workers. Before any launch source is imported, Linux Landlock restricts writes to worker scratch storage and excludes device access. Seccomp denies process creation, networking, device control, and filesystem metadata mutation; Python audit hooks turn common attempts into explicit errors. The controller may start evaluator workers, but launch code cannot start child processes. Failure to establish containment stops evaluation, with no unsafe fallback. Linux Landlock and the system seccomp library are required; no dependency declarations are changed by the tool. + +The worker uses the real launch libraries, resets their known state between cases, disables ROS graph discovery and GUI/spin behavior, and rejects unsupported operations. This is a migration checker for the inspected launch patterns, not a general interpreter for arbitrary side-effectful Python. Hardware execution and runtime integration testing remain separate activities. + +## Reports and validation + +- `plan.json`: revisions, entrypoints, domains, profiles, reductions and coverage counts. +- `cases.jsonl`: every evaluated case, status, and references to findings. +- `differences.diff`: each distinct field difference or blocker, with an example input and occurrence count. +- `summary.json`: result totals, cache statistics, scope and grouped findings. +- Finding JSON files: example inputs and a reference to the shared manifests under `examples/`, including source chains and diagnostic traces. + +Finding deduplication changes presentation only: it does not suppress later experiments. Exit status is successful only when every evaluated case is equivalent; differences and unresolved cases have distinct non-success statuses. A successful result still applies only to the declared domains and supported launch instructions. + +Workers must acknowledge successful containment before any cases are submitted. Worker startup failures, exits, protocol failures and timeouts abort the run immediately instead of becoming repeated per-case findings. An aborted report records `aborted`, `failure` and `completed_cases` in `summary.json`, and marks `differences.diff` as incomplete. Completed cases remain available; unevaluated cases are excluded from result counts. + +Run the focused tests and format checks through Pixi: + +```sh +pixi run -e default pytest -q scripts/launch_equivalence/tests +pixi run -e default ruff check scripts/launch_equivalence +pixi run -e default ruff format --check scripts/launch_equivalence +``` diff --git a/scripts/launch_equivalence/manifest.py b/scripts/launch_equivalence/manifest.py new file mode 100644 index 0000000000..b2cf3a4ce3 --- /dev/null +++ b/scripts/launch_equivalence/manifest.py @@ -0,0 +1,239 @@ +"""Stable process manifests and typed, field-level differences.""" + +import difflib +import hashlib +import json +import re +from pathlib import Path + +import yaml + + +def stable(value): + return json.dumps(value, sort_keys=True, ensure_ascii=False, allow_nan=False) + + +def digest(value): + return hashlib.sha256(stable(value).encode()).hexdigest() + + +def flatten(value, prefix=""): + if isinstance(value, dict): + for key, child in value.items(): + yield from flatten(child, f"{prefix}.{key}" if prefix else str(key)) + else: + yield prefix, value + + +def parameter_blocks(data, prefix=""): + if not isinstance(data, dict): + raise ValueError("Parameter file must contain a mapping") + for key, value in data.items(): + if key == "ros__parameters": + yield prefix or "/**", dict(flatten(value)) + else: + yield from parameter_blocks(value, f"{prefix}/{key}".replace("//", "/")) + + +def matches(selector, node): + tokens = selector.strip("/").split("/") + pattern = "" + for token in tokens: + if token == "**": + pattern += r"(?:/[^/]+)*" + elif token == "*": + pattern += r"/[^/]+" + elif "*" in token: + raise ValueError(f"Unsupported parameter selector: {selector}") + else: + pattern += "/" + re.escape(token) + return re.fullmatch(pattern, node) is not None + + +class Normalizer: + def __init__(self, replacements): + self.replacements = sorted(replacements, key=lambda pair: -len(pair[0])) + self.yaml_cache = {} + self.cache_hits = 0 + self.process_cache = {} + self.process_hits = 0 + + def clean(self, value): + if isinstance(value, str): + for original, replacement in self.replacements: + value = value.replace(original, replacement) + return value + if isinstance(value, dict): + return {self.clean(k): self.clean(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [self.clean(v) for v in value] + return value + + def read_params(self, path): + content = Path(path).read_text() + if content in self.yaml_cache: + self.cache_hits += 1 + else: + self.yaml_cache[content] = list(parameter_blocks(yaml.safe_load(content))) + return self.yaml_cache[content] + + def process(self, command, *, env, cwd, shell, policy, source): + """Decode ROS command options, retaining application argument and remap order.""" + command = list(command) + files = [Path(command[i + 1]).read_text() for i, arg in enumerate(command[:-1]) if arg == "--params-file"] + cache_command = list(command) + contents = iter(files) + for i, arg in enumerate(command[:-1]): + if arg == "--params-file": + cache_command[i + 1] = "" + cache_key = digest([cache_command, env, cwd, shell, policy]) + if cache_key in self.process_cache: + self.process_hits += 1 + return self.process_cache[cache_key] | {"source": self.clean(source), "raw_command": self.clean(command)} + app, remaps, parameters, options = [], [], [], [] + name, namespace = None, "/" + identity_rules = set() + ros_args = False + index = 0 + while index < len(command): + token = command[index] + index += 1 + if token == "--ros-args": + ros_args = True + continue + if ros_args and token == "--": + ros_args = False + continue + if not ros_args: + app.append(token) + continue + if token in { + "-r", + "--remap", + "-p", + "--param", + "--params-file", + "--log-level", + "--log-config-file", + "--enclave", + "-e", + }: + value = command[index] + index += 1 + if token in {"-r", "--remap"}: + key, target = value.split(":=", 1) + if key in {"__node", "__name"}: + if "name" in identity_rules: + raise ValueError("Multiple node-name remap rules require native remap resolution") + identity_rules.add("name") + name = target + elif key == "__ns": + if "namespace" in identity_rules: + raise ValueError("Multiple namespace remap rules require native remap resolution") + identity_rules.add("namespace") + namespace = "/" + target.strip("/") + else: + remaps.append([key, target]) + elif token in {"-p", "--param"}: + key, target = value.split(":=", 1) + parameters.append(("inline", key, yaml.safe_load(target))) + elif token == "--params-file": + parameters.append(("file", value, self.read_params(value))) + elif token == "--log-level": + options.append(["--log-level", value.upper()]) + else: + options.append([token, value]) + else: + options.append([token]) + fullname = namespace.rstrip("/") + "/" + name if name else None + effective = {} + unresolved_selectors = [] + provenance = [] + for kind, key, value in parameters: + provenance.append({"kind": kind, "source": self.clean(key)}) + if kind == "inline": + if ":" in key: + qualifier, key = key.split(":", 1) + if name is None: + unresolved_selectors.append([qualifier, {key: value}]) + continue + if qualifier != name: + continue + effective[key] = value + else: + for selector, values in value: + if selector == "/**" or (fullname and matches(selector, fullname)): + effective.update(values) + elif fullname is None: + unresolved_selectors.append([selector, values]) + result = self.clean( + { + "application_command": app, + "node_name": name, + "namespace": namespace, + "remaps": remaps, + "parameters": effective, + "unresolved_parameter_selectors": unresolved_selectors, + "ros_options": options, + "environment": env, + "cwd": cwd, + "shell": shell, + "policy": policy, + "source": source, + "raw_command": command, + "parameter_sources": provenance, + } + ) + self.process_cache[cache_key] = result + return dict(result) + + +def semantic(manifest): + return {k: v for k, v in manifest.items() if k not in {"source", "raw_command", "parameter_sources"}} + + +def differences(old, new): + """Align processes by identity and occurrence, preserving duplicate processes.""" + + def indexed(records): + result = {} + for record in records: + command = record.get("application_command", []) + identity = ( + record.get("node_name") + or record.get("path") + or (command[0] if command else record.get("kind", "event")) + ) + identity = f"/{record.get('process_boundary', '')}/{record.get('namespace', '/')}/{identity}" + while "//" in identity: + identity = identity.replace("//", "/") + occurrence = 0 + while f"{identity}#{occurrence}" in result: + occurrence += 1 + result[f"{identity}#{occurrence}"] = record + return result + + left, right = indexed(old), indexed(new) + result = [] + if list(left) != list(right): + result.append( + {"process": "@launch", "field": "order", "old": {"value": list(left)}, "new": {"value": list(right)}} + ) + for key in sorted(left.keys() | right.keys()): + before = dict(flatten(semantic(left[key]))) if key in left else {} + after = dict(flatten(semantic(right[key]))) if key in right else {} + for field in sorted(before.keys() | after.keys()): + a = {"value": before[field]} if field in before else {"absent": True} + b = {"value": after[field]} if field in after else {"absent": True} + if stable(a) != stable(b): + result.append({"process": key, "field": field, "old": a, "new": b}) + return result + + +def render_diff(delta): + left, right = [], [] + for item in delta: + label = f"{item['process']}.{item['field']}" + left.append(f"{label}: {stable(item['old'])}\n") + right.append(f"{label}: {stable(item['new'])}\n") + return "".join(difflib.unified_diff(left, right, fromfile="ros2 launch", tofile="better-launch")) diff --git a/scripts/launch_equivalence/sandbox.py b/scripts/launch_equivalence/sandbox.py new file mode 100644 index 0000000000..8e184f91ec --- /dev/null +++ b/scripts/launch_equivalence/sandbox.py @@ -0,0 +1,150 @@ +"""Linux containment for offline launch evaluation; no optional unsafe fallback.""" + +import ctypes +import errno +import os +import sys +from pathlib import Path + + +class UnsafeOperation(BaseException): + """Escape ordinary launch exception handlers when an operation is forbidden.""" + + +def contain(scratch: Path) -> int: + """Restrict filesystem writes and deny process creation, networking and device control.""" + libc = ctypes.CDLL(None, use_errno=True) + libc.syscall.restype = ctypes.c_long + # Landlock syscall numbers are shared by the supported Linux architectures. + create, add, restrict = 444, 445, 446 + abi = libc.syscall(create, 0, 0, 1) + if abi < 0: + error = ctypes.get_errno() + reason = { + errno.ENOSYS: "the kernel does not implement Landlock, or an outer sandbox hides its syscalls", + errno.EOPNOTSUPP: "Landlock is disabled in the running kernel", + errno.EPERM: "the syscall is denied, possibly by an outer container or sandbox", + }.get(error, "the kernel rejected the Landlock ABI query") + raise RuntimeError( + f"Landlock unavailable: {errno.errorcode.get(error, error)} ({os.strerror(error)}); {reason}. " + "Run on a host with Landlock enabled and its syscalls permitted. No launch files were evaluated." + ) + if abi < 3: + raise RuntimeError( + f"Landlock ABI {abi} detected; ABI >= 3 is required for file truncation protection. " + "Use a newer kernel with Landlock enabled. No launch files were evaluated." + ) + + class Ruleset(ctypes.Structure): + _fields_ = [("handled_access_fs", ctypes.c_uint64)] + + class PathRule(ctypes.Structure): + _pack_ = 1 + _fields_ = [("allowed_access", ctypes.c_uint64), ("parent_fd", ctypes.c_int32)] + + handled = (1 << 15) - 1 + ruleset = libc.syscall(create, ctypes.byref(Ruleset(handled)), ctypes.sizeof(Ruleset), 0) + if ruleset < 0: + raise OSError(ctypes.get_errno(), "landlock_create_ruleset") + + def allow(path, access): + fd = os.open(path, getattr(os, "O_PATH", 0x200000) | os.O_CLOEXEC) + try: + rule = PathRule(access, fd) + if libc.syscall(add, ruleset, 1, ctypes.byref(rule), 0): + raise OSError(ctypes.get_errno(), f"landlock_add_rule: {path}") + finally: + os.close(fd) + + try: + for path in Path("/").iterdir(): + if path.name == "dev" or path.is_symlink(): + continue + allow(path, (1 << 2) | ((1 << 3) if path.is_dir() else 0)) + for path in ("/dev/null", "/dev/urandom"): + allow(path, 1 << 2) + allow(scratch, handled & ~(1 << 0)) + if libc.prctl(38, 1, 0, 0, 0) or libc.syscall(restrict, ruleset, 0): + raise OSError(ctypes.get_errno(), "landlock_restrict_self") + finally: + os.close(ruleset) + + seccomp = ctypes.CDLL("libseccomp.so.2", use_errno=True) + seccomp.seccomp_init.argtypes = [ctypes.c_uint32] + seccomp.seccomp_init.restype = ctypes.c_void_p + seccomp.seccomp_syscall_resolve_name.argtypes = [ctypes.c_char_p] + seccomp.seccomp_rule_add.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_int, ctypes.c_uint] + seccomp.seccomp_load.argtypes = [ctypes.c_void_p] + seccomp.seccomp_release.argtypes = [ctypes.c_void_p] + ctx = seccomp.seccomp_init(0x7FFF0000) + if not ctx: + raise RuntimeError("seccomp_init failed") + try: + for name in ( + "execve", + "execveat", + "fork", + "vfork", + "clone", + "clone3", + "socket", + "socketpair", + "connect", + "bind", + "ioctl", + "kill", + "tkill", + "tgkill", + "ptrace", + "process_vm_writev", + "mount", + "umount2", + "unshare", + "setns", + "io_uring_setup", + "bpf", + "chmod", + "fchmod", + "fchmodat", + "fchmodat2", + "chown", + "fchown", + "lchown", + "fchownat", + "utime", + "utimes", + "futimesat", + "utimensat", + "setxattr", + "lsetxattr", + "fsetxattr", + "removexattr", + "lremovexattr", + "fremovexattr", + ): + number = seccomp.seccomp_syscall_resolve_name(name.encode()) + # Python probes terminal capabilities while opening ordinary text streams. + error = errno.ENOTTY if name == "ioctl" else errno.EPERM + if number >= 0 and seccomp.seccomp_rule_add(ctx, 0x50000 | error, number, 0): + raise RuntimeError(f"Could not restrict syscall {name}") + if seccomp.seccomp_load(ctx): + raise RuntimeError("seccomp_load failed") + finally: + seccomp.seccomp_release(ctx) + + def audit(event, args): + if event in {"subprocess.Popen", "os.system", "os.fork", "os.posix_spawn", "socket.__new__"}: + raise UnsafeOperation(f"Blocked during launch evaluation: {event}") + + sys.addaudithook(audit) + return abi + + +if __name__ == "__main__": + # The controller owns scratch cleanup; containment applies only to this subprocess. + try: + abi = contain(Path(sys.argv[1])) + except Exception as exception: + print(f"Sandbox check failed: {exception}", file=sys.stderr) + raise SystemExit(2) from None + print(f"Sandbox check passed: Landlock ABI {abi}; seccomp installed.") diff --git a/scripts/launch_equivalence/tests/conftest.py b/scripts/launch_equivalence/tests/conftest.py new file mode 100644 index 0000000000..5801beaefb --- /dev/null +++ b/scripts/launch_equivalence/tests/conftest.py @@ -0,0 +1,4 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) diff --git a/scripts/launch_equivalence/tests/test_verifier.py b/scripts/launch_equivalence/tests/test_verifier.py new file mode 100644 index 0000000000..4e0955ca47 --- /dev/null +++ b/scripts/launch_equivalence/tests/test_verifier.py @@ -0,0 +1,338 @@ +import ctypes +import errno +import json + +import pytest +import sandbox +import verify +from manifest import Normalizer, differences, semantic +from verify import REPO, Worker, WorkerError, assignments, domains_for + + +@pytest.mark.parametrize( + ("abi", "error", "message"), + [(2, 0, "ABI 2 detected"), (-1, errno.ENOSYS, "ENOSYS"), (-1, errno.EOPNOTSUPP, "disabled")], +) +def test_landlock_failure_diagnostics(monkeypatch, tmp_path, abi, error, message): + class Libc: + class Syscall: + def __call__(self, *args): + ctypes.set_errno(error) + return abi + + syscall = Syscall() + + monkeypatch.setattr(sandbox.ctypes, "CDLL", lambda *args, **kwargs: Libc()) + with pytest.raises(RuntimeError, match=message): + sandbox.contain(tmp_path) + + +def test_worker_startup_failure_preserves_cause(monkeypatch, tmp_path): + (tmp_path / "worker.py").write_text('raise RuntimeError("Landlock unavailable: test failure")\n') + monkeypatch.setattr(verify, "HERE", tmp_path) + with pytest.raises(WorkerError, match="Landlock unavailable: test failure"): + Worker(tmp_path, tmp_path, "ros", 5) + + +@pytest.mark.parametrize( + ("response", "message"), + [ + ('raise RuntimeError("worker crashed")', "worker crashed"), + ('print("invalid", flush=True)', "invalid JSON"), + ('print("{}", flush=True)', "invalid evaluation"), + ('print("{", end="", flush=True); time.sleep(30)', "timed out"), + ], +) +def test_worker_transport_failure_is_fatal(monkeypatch, tmp_path, response, message): + (tmp_path / "worker.py").write_text( + "import sys, time\nprint('{\"ready\": true}', flush=True)\nsys.stdin.readline()\n" + response + "\n" + ) + monkeypatch.setattr(verify, "HERE", tmp_path) + worker = Worker(tmp_path, tmp_path, "ros", 5) + worker.timeout = 0.2 + try: + with pytest.raises(WorkerError, match=message): + worker.evaluate({"file": "unused"}) + finally: + worker.close() + assert worker.process.poll() is not None + + +@pytest.mark.parametrize("completed", [0, 1]) +def test_controller_aborts_without_counting_unattempted_cases(monkeypatch, tmp_path, completed): + workers = [] + + class FailingWorker: + def __init__(self, root, scratch, engine, timeout): + if not completed and engine == "better": + raise WorkerError("startup failure") + self.calls = 0 + self.closed = False + workers.append(self) + + def evaluate(self, request): + self.calls += 1 + if self.calls > completed: + raise WorkerError("evaluation failure") + return {"records": [], "errors": []} + + def close(self): + self.closed = True + + monkeypatch.setattr(verify, "Worker", FailingWorker) + monkeypatch.setattr(verify, "export", lambda *args: None) + monkeypatch.setattr(verify, "discover", lambda *args: [{"entrypoint": "p/f", "old": "old", "new": "new"}]) + monkeypatch.setattr( + verify, + "git", + lambda *args: ( + '' + if args[-1].endswith(":old") + else "@launch_this\ndef entry(sim: bool = False):\n pass\n" + ) + if args[0] == "show" + else "revision", + ) + report = tmp_path / "report" + monkeypatch.setattr(verify.sys, "argv", ["verify.py", "--output", str(report)]) + assert verify.main() == 2 + summary = json.loads((report / "summary.json").read_text()) + assert summary["aborted"] is True + assert summary["completed_cases"] == completed + assert summary["planned_cases"] == 2 + assert summary["results"] == {"equivalent": completed, "different": 0, "unresolved": 0} + assert (report / "differences.diff").read_text().startswith("ABORTED") + assert all(worker.closed for worker in workers) + assert all(worker.calls <= completed + 1 for worker in workers) + + +def test_default_reduction_preserves_different_defaults_and_conditional_arguments(): + old = '' + new = "@launch_this\ndef entry(a: bool = True, b: bool = True, c: bool = True):\n pass\n" + domains, reductions, _ = domains_for(old, new) + assert domains["a"]["values"] == ["false", "true"] + assert domains["b"]["values"] == [None, "false", "true"] + assert domains["c"]["values"] == [None, "false", "true"] + assert [r["argument"] for r in reductions] == ["a"] + assert len(list(assignments(domains))) == 18 + + +def test_domains_do_not_execute_source(): + source = "@launch_this\ndef entry(a: str = dangerous()):\n raise RuntimeError()\n" + domains, reductions, bounded = domains_for("", source) + assert list(assignments(domains)) == [{}] + assert reductions == [] + assert bounded == ["a"] + + +def test_domain_requires_a_list_instead_of_iterating_a_string(): + with pytest.raises(ValueError, match="nonempty list"): + domains_for("", "@launch_this\ndef entry(sim: bool = False):\n pass\n", {"sim": "false"}) + + +def test_reduction_keeps_arguments_used_before_their_declaration(): + old = '' + new = "@launch_this\ndef entry(sim: bool = False):\n pass\n" + domains, reductions, _ = domains_for(old, new) + assert domains["sim"]["values"] == [None, "false", "true"] + assert not reductions + + +def test_reduction_keeps_raw_cli_observations(): + old = '' + new = "import sys\n@launch_this\ndef entry(sim: bool = False):\n print(sys.argv)\n" + domains, reductions, _ = domains_for(old, new) + assert domains["sim"]["values"] == [None, "false", "true"] + assert not reductions + + +def test_parameter_precedence_types_and_cache_invalidation(tmp_path): + config = tmp_path / "config.yaml" + config.write_text( + "/**:\n ros__parameters:\n enabled: true\n gain: 2\n/ns/node:\n ros__parameters:\n gain: 3\n" + ) + normalizer = Normalizer([]) + command = [ + "package://pkg/exe", + "--ros-args", + "-r", + "__node:=node", + "-r", + "__ns:=/ns", + "--params-file", + str(config), + "-p", + 'enabled:="false"', + ] + kwargs = dict(env={}, cwd="/", shell=False, policy={}, source=[]) + first = normalizer.process(command, **kwargs) + assert first["parameters"] == {"enabled": "false", "gain": 3} + assert semantic(normalizer.process(command, **kwargs)) == semantic(first) + assert normalizer.process_hits == 1 + config.write_text("/**:\n ros__parameters:\n gain: 4\n") + assert normalizer.process(command, **kwargs)["parameters"]["gain"] == 4 + + +def test_diff_preserves_types_and_duplicate_nodes(): + node = {"node_name": "node", "namespace": "/", "parameters": {"enabled": True}} + other = node | {"parameters": {"enabled": 1}} + assert differences([node], [other])[0]["field"] == "parameters.enabled" + assert any(d["process"] == "/node#1" for d in differences([node, node], [node])) + + +@pytest.fixture +def launch_pair(tmp_path): + root = tmp_path / "revision" + package = root / "src/test_package" + package.mkdir(parents=True) + (package / "package.xml").write_text("test_package") + (root / "src/lib").mkdir() + (root / "src/lib/better_launch").symlink_to(REPO / "src/lib/better_launch", target_is_directory=True) + workers = [] + + def evaluate(old, new, arguments=None): + (package / "old.launch").write_text(old) + (package / "new.launch.py").write_text(new) + results = [] + for engine, file in (("ros", "old.launch"), ("better", "new.launch.py")): + worker = Worker(root, tmp_path / f"worker-{len(workers)}", engine, 30) + workers.append(worker) + results.append(worker.evaluate({"file": f"src/test_package/{file}", "arguments": arguments or {}})) + return results + + yield evaluate, package, tmp_path + for worker in workers: + worker.close() + + +def test_native_evaluators_equal_without_executable(launch_pair): + evaluate, _, _ = launch_pair + old, new = evaluate( + '', + 'from better_launch import BetterLaunch, launch_this\n@launch_this\ndef entry(sim: bool = False):\n BetterLaunch().node("missing_binary", "missing", "test_node", params={"use_sim_time": sim}, log_level=None, lifecycle_target=None)\n', + {"sim": "true"}, + ) + assert not old["errors"], "\n".join(e.get("traceback", e["message"]) for e in old["errors"]) + assert not new["errors"], new + assert len(old["records"]) == len(new["records"]) == 1 + assert differences(old["records"], new["records"]) == [] + + +def test_native_detects_string_boolean_forwarding(launch_pair): + evaluate, package, _ = launch_pair + (package / "child.launch.py").write_text( + 'from better_launch import BetterLaunch, launch_this\n@launch_this\ndef child(enabled: bool = False):\n if enabled:\n BetterLaunch().node("pkg", "exe", "unexpected", log_level=None, lifecycle_target=None)\n' + ) + old, new = evaluate( + '', + 'from better_launch import BetterLaunch, launch_this\n@launch_this\ndef entry(enabled: str = "false"):\n BetterLaunch().include("test_package", "child.launch.py", enabled=enabled)\n', + {"enabled": "false"}, + ) + assert not old["errors"], old + assert not new["errors"], new + assert old["records"] == [] + assert new["records"][0]["node_name"] == "unexpected" + + +@pytest.mark.parametrize( + "operation", + [ + 'import subprocess; subprocess.run(["/bin/true"])', + "import socket; socket.socket()", + "import ctypes; assert ctypes.CDLL(None, use_errno=True).socket(2, 1, 0) == -1", + ], +) +def test_containment(launch_pair, operation): + evaluate, _, _ = launch_pair + _, result = evaluate( + "", operation + "\nfrom better_launch import launch_this\n@launch_this\ndef entry():\n pass\n" + ) + if "ctypes" in operation: + assert result["errors"] == [], result + else: + assert any(e["type"] == "UnsafeOperation" for e in result["errors"]), result + + +def test_landlock_prevents_source_writes(launch_pair): + evaluate, package, _ = launch_pair + marker = package / "should_not_exist" + _, result = evaluate("", f'from pathlib import Path\nPath({str(marker)!r}).write_text("bad")\n') + assert result["errors"], result + assert not marker.exists() + + +def test_unknown_action_is_unresolved(launch_pair): + evaluate, _, _ = launch_pair + old, _ = evaluate( + '', + "from better_launch import launch_this\n@launch_this\ndef entry():\n pass\n", + ) + assert old["errors"] + json.dumps(old) + + +def test_timer_is_evaluated_without_waiting(launch_pair): + evaluate, _, _ = launch_pair + old, new = evaluate( + '', + 'from better_launch import BetterLaunch, launch_this\n@launch_this\ndef entry():\n bl = BetterLaunch()\n bl.run_later(600.0, lambda: bl.node("pkg", "exe", "delayed", log_level=None, lifecycle_target=None))\n', + ) + assert not old["errors"], old + assert not new["errors"], new + assert old["records"][0]["policy"]["triggers"][0]["after_seconds"] == 600 + assert differences(old["records"], new["records"]) == [] + + +def test_native_component_request_without_ros_graph(launch_pair): + evaluate, package, _ = launch_pair + xml = '' + (package / "components.launch").write_text(xml) + old, new = evaluate( + xml, + 'from better_launch import BetterLaunch, launch_this\n@launch_this\ndef entry():\n BetterLaunch().include("test_package", "components.launch")\n', + ) + assert not old["errors"], old + assert not new["errors"], new + assert old["records"][1]["plugin"] == "Example" + assert old["records"][1]["parameters"] == {"enabled": True} + assert differences(old["records"], new["records"]) == [] + + +def test_nested_launcher_is_expanded_without_spawning_it(launch_pair): + evaluate, package, _ = launch_pair + (package / "child.launch").write_text( + '' + ) + (package / "child.launch.py").write_text( + 'from better_launch import BetterLaunch, launch_this\n@launch_this\ndef child(sim: bool = False):\n BetterLaunch().node("pkg", "exe", "child", params={"use_sim_time": sim}, log_level=None, lifecycle_target=None)\n' + ) + old, new = evaluate( + '', + 'from better_launch import BetterLaunch, launch_this\n@launch_this\ndef entry():\n BetterLaunch().process(["bl", "test_package", "child.launch.py", "--sim", "true"])\n', + ) + assert not old["errors"], old + assert not new["errors"], new + assert len(old["records"]) == len(new["records"]) == 2 + assert old["records"][1]["parameters"] == {"use_sim_time": True} + assert differences(old["records"], new["records"]) == [] + + +def test_better_launch_swallowed_preparation_error_is_not_success(launch_pair): + evaluate, package, _ = launch_pair + (package / "broken.yaml").write_text("this: [is: not: yaml") + _, new = evaluate( + "", + 'from better_launch import BetterLaunch, launch_this\n@launch_this\ndef entry():\n bl = BetterLaunch()\n bl.node("pkg", "exe", "node", params=bl.find("test_package", "broken.yaml"))\n', + ) + assert new["errors"], new + assert new["records"] == [] + + +def test_missing_parameter_file_is_not_silently_skipped(launch_pair): + evaluate, _, _ = launch_pair + old, new = evaluate( + '', + 'from better_launch import BetterLaunch, launch_this\n@launch_this\ndef entry():\n BetterLaunch().node("pkg", "exe", "node", param_files="/definitely/missing/config.yaml")\n', + ) + assert old["errors"], old + assert new["errors"], new diff --git a/scripts/launch_equivalence/verify.py b/scripts/launch_equivalence/verify.py new file mode 100644 index 0000000000..0e50f3ebe4 --- /dev/null +++ b/scripts/launch_equivalence/verify.py @@ -0,0 +1,500 @@ +#!/usr/bin/env python3 +"""Compare old ROS launch and migrated better-launch without starting processes.""" + +import argparse +import ast +import itertools +import json +import math +import os +import selectors +import subprocess +import sys +import tarfile +import tempfile +import time +import xml.etree.ElementTree as ET +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from manifest import differences, digest, render_diff, stable + +HERE = Path(__file__).resolve().parent +REPO = HERE.parents[1] + + +def git(*args): + return subprocess.check_output(["git", "-C", str(REPO), *args], text=True).strip() + + +def python_arguments(source): + """Inspect literal function signatures without importing executable launch code.""" + result = {} + for node in ast.parse(source).body: + if not isinstance(node, ast.FunctionDef): + continue + if not any( + (isinstance(d, ast.Name) and d.id == "launch_this") + or (isinstance(d, ast.Call) and isinstance(d.func, ast.Name) and d.func.id == "launch_this") + for d in node.decorator_list + ): + continue + args = node.args.posonlyargs + node.args.args + defaults = [None] * (len(args) - len(node.args.defaults)) + node.args.defaults + for arg, default in zip(args + node.args.kwonlyargs, defaults + node.args.kw_defaults, strict=True): + literal = False + value = None + if default is not None: + try: + value = ast.literal_eval(default) + literal = True + except (ValueError, TypeError): + pass + result[arg.arg] = { + "type": ast.unparse(arg.annotation) if arg.annotation else None, + "default": value, + "literal": literal, + } + return result + + +def xml_arguments(source): + result = {} + declarations_first = True + for node in ET.fromstring(source): + if node.tag != "arg": + declarations_first = False + continue + name = node.attrib["name"] + value = node.get("default") + literal = ( + declarations_first + and value is not None + and "$(" not in value + and not ("if" in node.attrib or "unless" in node.attrib) + ) + if name in result: + literal = False + result[name] = {"default": value, "literal": literal} + return result + + +def binding_only(source): + """Conservatively exclude entrypoints that may inspect argument presence or raw CLI input.""" + allowed_imports = {"better_launch", "logging", "os", "pathlib", "yaml", "datetime"} + forbidden_names = { + "argv", + "orig_argv", + "sys", + "inspect", + "eval", + "exec", + "compile", + "getattr", + "globals", + "locals", + "vars", + "__import__", + "get_current_context", + "get_parameter_source", + } + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Import) and any( + alias.name.split(".")[0] not in allowed_imports for alias in node.names + ): + return False + if isinstance(node, ast.ImportFrom) and (not node.module or node.module.split(".")[0] not in allowed_imports): + return False + if isinstance(node, ast.Name) and node.id in forbidden_names: + return False + if isinstance(node, ast.Attribute) and node.attr in forbidden_names: + return False + if isinstance(node, ast.Constant) and isinstance(node.value, str) and "/proc/" in node.value: + return False + return True + + +def domains_for(old_source, new_source, overrides=None): + before = xml_arguments(old_source) if old_source.lstrip().startswith("<") else {} + after = python_arguments(new_source) + domains, reductions, bounded = {}, [], [] + for name in sorted(before.keys() | after.keys() | (overrides or {}).keys()): + info = after.get(name, {}) + values = (overrides or {}).get(name) + if values is None: + if info.get("type") == "bool" or before.get(name, {}).get("default") in {"true", "false"}: + values = [None, "false", "true"] + else: + values = [None] + bounded.append(name) + if ( + not isinstance(values, list) + or not values + or not all(value is None or isinstance(value, str) for value in values) + ): + raise ValueError(f"Domain {name} must be a nonempty list of strings or null (omitted)") + original = list(dict.fromkeys(values)) + values = list(original) + old = before.get(name, {}) + # ROS receives these arguments through IncludeLaunchDescription, not LaunchContext.argv. + # Literal, unconditional XML defaults produce exactly the same LaunchConfiguration; + # Click produces exactly the same typed value on the better-launch side. + if info.get("type") == "bool" and info.get("literal") and old.get("literal") and binding_only(new_source): + default = str(info["default"]).lower() + if type(info["default"]) is bool and old["default"] == default and None in values and default in values: + values.remove(None) + reductions.append( + { + "argument": name, + "omitted_equals": default, + "reason": "identical unconditional XML default and typed Python default", + } + ) + domains[name] = {"values": values, "original": original} + return domains, reductions, bounded + + +def assignments(domains): + keys = list(domains) + for values in itertools.product(*(domains[key]["values"] for key in keys)): + yield {key: value for key, value in zip(keys, values, strict=True) if value is not None} + + +def discover(base, head): + old_files = set(git("ls-tree", "-r", "--name-only", base).splitlines()) + new_files = git("ls-tree", "-r", "--name-only", head).splitlines() + result = [] + for new in new_files: + if "/launch/" not in new or not new.endswith(".launch.py") or new.startswith("src/lib/"): + continue + source = git("show", f"{head}:{new}") + if not python_arguments(source): + # Functions without arguments are valid launch entrypoints too. + if "@launch_this" not in source: + continue + old = new if new in old_files else new.removesuffix(".py") + if old in old_files: + result.append( + {"old": old, "new": new, "entrypoint": new.split("/launch/")[0].split("/")[-1] + "/" + Path(new).name} + ) + return result + + +def export(revision, path): + path.mkdir() + with tempfile.TemporaryFile() as archive: + subprocess.run(["git", "-C", str(REPO), "archive", revision, "src"], stdout=archive, check=True) + archive.seek(0) + with tarfile.open(fileobj=archive) as tar: + tar.extractall(path, filter="data") + + +class WorkerError(RuntimeError): + """The evaluator infrastructure failed; further cases cannot be compared.""" + + +class Worker: + def __init__(self, root, scratch, engine, timeout): + self.timeout = timeout + self.engine = engine + self.pending = b"" + self.stderr = tempfile.TemporaryFile(mode="w+") + self.process = subprocess.Popen( + [ + sys.executable, + "-B", + str(HERE / "worker.py"), + "--root", + str(root), + "--scratch", + str(scratch), + "--engine", + engine, + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=self.stderr, + text=True, + bufsize=1, + ) + self.selector = selectors.DefaultSelector() + self.selector.register(self.process.stdout, selectors.EVENT_READ) + try: + if self.receive() != {"ready": True}: + raise WorkerError(f"{self.engine} worker sent an invalid startup response") + except WorkerError: + self.close() + raise + + def fail(self, message): + if self.process.poll() is None: + self.process.kill() + self.process.wait() + self.stderr.seek(0) + detail = self.stderr.read()[-6000:].strip() + raise WorkerError(f"{self.engine} worker {message}" + (f":\n{detail}" if detail else "")) + + def receive(self): + deadline = time.monotonic() + self.timeout + while b"\n" not in self.pending: + remaining = deadline - time.monotonic() + if remaining <= 0 or not self.selector.select(remaining): + self.fail(f"timed out after {self.timeout:g}s") + chunk = os.read(self.process.stdout.fileno(), 65536) + if not chunk: + self.fail("stopped") + self.pending += chunk + line, self.pending = self.pending.split(b"\n", 1) + try: + return json.loads(line) + except (json.JSONDecodeError, UnicodeDecodeError): + self.fail("sent an invalid JSON response") + + def evaluate(self, request, ancestors=()): + identity = stable(request) + if identity in ancestors or len(ancestors) >= 16: + return {"records": [], "errors": [{"type": "Unsupported", "message": "Recursive nested launch"}]} + try: + self.process.stdin.write(stable(request) + "\n") + self.process.stdin.flush() + result = self.receive() + if not isinstance(result, dict) or not all(isinstance(result.get(k), list) for k in ("records", "errors")): + self.fail("sent an invalid evaluation response") + for child in result.pop("children", []): + nested = self.evaluate(child["request"], ancestors + (identity,)) + result["records"].extend( + record | {"process_boundary": child["boundary"] + "/" + record.get("process_boundary", "")} + for record in nested["records"] + ) + result["errors"].extend(error | {"process_boundary": child["boundary"]} for error in nested["errors"]) + result["cache"] = nested.get("cache", result.get("cache", {})) + return result + except BrokenPipeError: + self.fail("closed its input pipe") + + def close(self): + try: + self.process.stdin.close() + except BrokenPipeError: + pass + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait() + self.selector.close() + self.process.stdout.close() + self.stderr.close() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", default="main", help="Old ROS launch revision") + parser.add_argument("--head", default="HEAD", help="Migrated revision; uncommitted files are excluded") + parser.add_argument("--entry", action="append", help="Package/launch-file selector; repeatable") + parser.add_argument("--domains", type=Path, help="JSON with per-entrypoint arguments and environment profiles") + parser.add_argument("--output", type=Path, default=Path("/tmp/launch-equivalence-report")) + parser.add_argument( + "--plan", action="store_true", help="Print inventory, domains, and counts without evaluating code" + ) + parser.add_argument( + "--defaults-only", action="store_true", help="Compare omitted arguments only; reports limited coverage" + ) + parser.add_argument("--max-cases", type=int, default=100000, help="Refuse larger matrices; never silently truncate") + parser.add_argument("--timeout", type=float, default=30, help="Worker response timeout") + parser.add_argument( + "--check-sandbox", action="store_true", help="Check containment without evaluating launch files" + ) + options = parser.parse_args() + if options.timeout <= 0 or options.max_cases < 1: + parser.error("Timeout and case limit must be positive") + if options.check_sandbox: + with tempfile.TemporaryDirectory(prefix="launch-equivalence-check-") as scratch: + try: + return subprocess.run( + [sys.executable, "-B", str(HERE / "sandbox.py"), scratch], timeout=options.timeout + ).returncode + except subprocess.TimeoutExpired: + print("Sandbox check timed out", file=sys.stderr) + return 2 + base, head = git("rev-parse", f"{options.base}^{{commit}}"), git("rev-parse", f"{options.head}^{{commit}}") + specification = json.loads(options.domains.read_text()) if options.domains else {} + profiles = specification.get("environments", {"unset_robot": {}}) + if not profiles: + parser.error("At least one environment profile is required") + inventory = discover(base, head) + if not inventory: + parser.error("No paired migrated launch entrypoints found in the selected revisions") + unknown_domains = set(specification.get("arguments", {})) - {pair["entrypoint"] for pair in inventory} + if unknown_domains: + parser.error(f"Unknown entrypoints in domains file: {sorted(unknown_domains)}") + if options.entry: + selected = set(options.entry) + inventory = [pair for pair in inventory if pair["entrypoint"] in selected or pair["new"] in selected] + missing = selected - {key for pair in inventory for key in (pair["entrypoint"], pair["new"])} + if missing: + parser.error(f"Unknown entrypoints: {sorted(missing)}") + plan = [] + for pair in inventory: + overrides = specification.get("arguments", {}).get(pair["entrypoint"], {}) + domains, reductions, bounded = domains_for( + git("show", f"{base}:{pair['old']}"), git("show", f"{head}:{pair['new']}"), overrides + ) + if options.defaults_only: + domains = {key: {"values": [None], "original": [None]} for key in domains} + reductions = [] + plan.append( + pair + | { + "domains": domains, + "reductions": reductions, + "default_only_arguments": bounded, + "cases": math.prod(len(d["values"]) for d in domains.values()) * len(profiles), + "covered_assignments": math.prod(len(d["original"]) for d in domains.values()) * len(profiles), + } + ) + planned = sum(pair["cases"] for pair in plan) + metadata = { + "base": base, + "head": head, + "environments": profiles, + "defaults_only": options.defaults_only, + "planned_cases": planned, + "covered_assignments": sum(pair["covered_assignments"] for pair in plan), + "entrypoints": plan, + } + if options.plan: + print(json.dumps(metadata, indent=2)) + return 0 + if planned > options.max_cases: + parser.error( + f"Matrix requires {planned} cases after reduction. Inspect --plan and supply domains or raise --max-cases." + ) + if options.output.exists() and any(options.output.iterdir()): + parser.error("Output directory is not empty; choose a fresh directory to keep reports separate") + options.output.mkdir(parents=True, exist_ok=True) + (options.output / "examples").mkdir() + (options.output / "plan.json").write_text(json.dumps(metadata, indent=2) + "\n") + groups = {} + counts = {"equivalent": 0, "different": 0, "unresolved": 0} + cache = {} + cache_hits = 0 + process_cache = {} + started = time.monotonic() + failure = None + with tempfile.TemporaryDirectory(prefix="launch-equivalence-") as temporary: + temporary = Path(temporary) + old_root, new_root = temporary / "old", temporary / "new" + export(base, old_root) + export(head, new_root) + workers = [] + try: + workers.append(Worker(old_root, temporary / "old-worker", "ros", options.timeout)) + workers.append(Worker(new_root, temporary / "new-worker", "better", options.timeout)) + with (options.output / "cases.jsonl").open("w") as cases, ThreadPoolExecutor(max_workers=2) as executor: + for pair in plan: + print(f"Comparing {pair['entrypoint']} ({pair['cases']} cases)", flush=True) + for profile, environment in profiles.items(): + for arguments in assignments(pair["domains"]): + requests = [ + dict(file=pair[side], arguments=arguments, environment=environment) + for side in ("old", "new") + ] + outputs, futures = [None, None], [] + for index, (worker, request) in enumerate(zip(workers, requests, strict=True)): + key = stable([index, request]) + if key in cache: + outputs[index] = cache[key] + cache_hits += 1 + else: + futures.append((index, key, executor.submit(worker.evaluate, request))) + for index, key, future in futures: + outputs[index] = cache[key] = future.result() + if len(cache) > 128: + cache.pop(next(iter(cache))) + process_cache[("old", "new")[index]] = outputs[index].get("cache", {}) + delta = differences(outputs[0]["records"], outputs[1]["records"]) + errors = { + side: result["errors"] + for side, result in zip(("old", "new"), outputs, strict=True) + if result["errors"] + } + status = "unresolved" if errors else "different" if delta else "equivalent" + counts[status] += 1 + case = { + "entrypoint": pair["entrypoint"], + "arguments": arguments, + "environment": profile, + "status": status, + "differences": delta, + "errors": errors, + } + signatures = [] + findings = [{"differences": [difference], "errors": {}} for difference in delta] + findings.extend( + {"differences": [], "errors": {side: [error]}} + for side, exceptions in errors.items() + for error in exceptions + ) + saved_example = None + for finding in findings: + signature = digest([pair["entrypoint"], finding]) + signatures.append(signature) + if signature not in groups: + example = case | finding + groups[signature] = {"count": 0, "example": example} + if saved_example is None: + saved_example = ( + f"examples/{digest([pair['entrypoint'], profile, arguments])}.json" + ) + (options.output / saved_example).write_text( + json.dumps({"old": outputs[0], "new": outputs[1]}, indent=2) + "\n" + ) + (options.output / f"{signature}.json").write_text( + json.dumps({"case": example, "manifests": saved_example}, indent=2) + "\n" + ) + groups[signature]["count"] += 1 + cases.write( + stable( + {key: value for key, value in case.items() if key not in {"differences", "errors"}} + | {"groups": signatures} + ) + + "\n" + ) + print(f" totals: {counts}", flush=True) + except WorkerError as exception: + failure = str(exception) + print(f"ABORTED: {failure}", file=sys.stderr) + finally: + for worker in workers: + worker.close() + summary = metadata | { + "results": counts, + "completed_cases": sum(counts.values()), + "aborted": failure is not None, + "failure": failure, + "cache_hits": cache_hits, + "process_cache": process_cache, + "elapsed_seconds": time.monotonic() - started, + "groups": groups, + "scope": "launch instructions in the declared finite domains; no runtime equivalence claim", + } + (options.output / "summary.json").write_text(json.dumps(summary, indent=2) + "\n") + with (options.output / "differences.diff").open("w") as report: + if failure: + report.write( + f"ABORTED after {sum(counts.values())} of {planned} cases; incomplete comparison.\n{failure}\n" + ) + for signature, group in groups.items(): + example = group["example"] + report.write(f"\n{example['entrypoint']} | {group['count']} cases | group {signature}\n") + report.write(f"arguments={stable(example['arguments'])} environment={example['environment']}\n") + report.write(render_diff(example["differences"])) + if example["errors"]: + report.write("UNRESOLVED: " + stable(example["errors"]) + "\n") + print(f"{counts}; report: {options.output}") + return 2 if failure or counts["unresolved"] else 1 if counts["different"] else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/launch_equivalence/worker.py b/scripts/launch_equivalence/worker.py new file mode 100644 index 0000000000..5167a5eb1b --- /dev/null +++ b/scripts/launch_equivalence/worker.py @@ -0,0 +1,668 @@ +"""Native launch evaluation in a contained, disposable worker process.""" + +import argparse +import datetime +import importlib +import inspect +import io +import json +import logging +import os +import runpy +import shlex +import shutil +import sys +import tempfile +import traceback +import xml.etree.ElementTree as ET +from concurrent.futures import Future +from contextlib import contextmanager, redirect_stderr, redirect_stdout +from pathlib import Path + +from manifest import Normalizer +from sandbox import UnsafeOperation, contain + + +class Captured(BaseException): + pass + + +class UnsupportedError(Exception): + pass + + +class PackageIndex: + def __init__(self, root, scratch): + self.root, self.scratch = root, scratch + self.packages = {} + for manifest in sorted(root.glob("src/**/package.xml")): + try: + name = ET.parse(manifest).getroot().findtext("name") + except ET.ParseError: + continue + if name in self.packages: + raise ValueError(f"Duplicate source package {name}") + self.packages[name] = manifest.parent + self.external = Path(sys.prefix) + self.used = {} + + def prefix(self, package): + if package not in self.packages or package in { + "launch", + "launch_xml", + "launch_yaml", + "launch_ros", + "ros2launch", + }: + if not (self.external / "share" / package).is_dir(): + raise UnsupportedError(f"Missing package resources: {package}") + return str(self.external) + prefix = self.scratch / "packages" / package + share = prefix / "share" / package + if not share.exists(): + # Files remain read-only through Landlock; newly generated files stay in scratch. + def overlay(source, destination): + destination.mkdir(parents=True) + for entry in source.iterdir(): + target = destination / entry.name + if entry.is_dir() and not entry.is_symlink(): + overlay(entry, target) + else: + target.symlink_to(entry) + + overlay(self.packages[package], share) + self.used[package] = share + return str(prefix) + + def share(self, package, **kwargs): + return str(Path(self.prefix(package)) / "share" / package) + + +class Evaluator: + def __init__(self, root, scratch, engine): + self.root, self.scratch, self.engine = root, scratch, engine + self.index = PackageIndex(root, scratch) + self.records, self.errors, self.sources, self.triggers = [], [], [], [] + self.raw_node = None + self.node_options = {} + self.saved_env = dict(os.environ) + self.deferred = [] + self.native_versions = {} + self.patch_native() + + def patch_native(self): + import ament_index_python.packages as packages + import launch + import xacro + from launch.actions import ExecuteProcess + from launch.actions.execute_local import ExecuteLocal + from launch.substitutions import Command, FindExecutable + from launch.utilities import perform_substitutions + from launch_ros.actions import Node as RosNode + from launch_ros.actions import node as ros_node_module + from launch_ros.substitutions import ExecutableInPackage + + self.launch = launch + self.RosNode = RosNode + self.ExecuteProcess = ExecuteProcess + self.perform = perform_substitutions + self.xacro = xacro + evaluate_parameters = ros_node_module.evaluate_parameters + + def checked_parameters(context, parameters): + evaluated = evaluate_parameters(context, parameters) + for parameter in evaluated: + if isinstance(parameter, Path) and not parameter.is_file(): + self.error(UnsupportedError(f"Missing parameter file: {parameter}")) + return evaluated + + ros_node_module.evaluate_parameters = checked_parameters + self.native_versions["launch"] = str(Path(launch.__file__).relative_to(Path(sys.prefix))) + + originals = { + packages.get_package_prefix: self.index.prefix, + packages.get_package_share_directory: self.index.share, + } + if self.engine == "better": + sys.path.insert(0, str(self.root / "src/lib/better_launch")) + import better_launch + + self.bl_module = importlib.import_module("better_launch.launcher") + self.bl_settings = importlib.import_module("better_launch.utils.settings") + self.bl_wrapper = importlib.import_module("better_launch.wrapper") + self.BL = better_launch.BetterLaunch + self.BNode = importlib.import_module("better_launch.elements.node").Node + self.native_versions["better_launch"] = better_launch.__version__ + + # Modules often import these helpers by value. + for module in tuple(sys.modules.values()): + if module is None or not hasattr(module, "__dict__"): + continue + for key, value in list(vars(module).items()): + if inspect.isfunction(value) and value in originals: + setattr(module, key, originals[value]) + + def executable(action, context): + package = self.perform(context, action.package) + name = self.perform(context, action.executable) + return f"package://{package}/{name}" + + ExecutableInPackage.perform = executable + FindExecutable.perform = lambda action, context: "command://" + self.perform(context, action.name) + Command.perform = lambda action, context: self.command(shlex.split(self.perform(context, action.command))) + ExecuteLocal.execute = lambda action, context: self.ros_process(action, context) + # Normal launch logging may create files at import time or during preparation. + launch.logging.launch_config.log_dir = str(self.scratch / "logs") + if self.engine == "better": + self.patch_better() + + def command(self, args): + if Path(args[0]).name == "cat" and len(args) == 2: + return Path(args[1]).read_text() + if Path(args[0]).name != "xacro" and args[0] != "command://xacro": + raise UnsupportedError(f"Command substitution requires execution: {args!r}") + mappings = {} + paths = [] + for value in args[1:]: + if ":=" in value: + key, value = value.split(":=", 1) + mappings[key] = value + elif value.startswith("-"): + raise UnsupportedError(f"Unsupported Xacro option: {value}") + else: + paths.append(value) + if len(paths) != 1: + raise UnsupportedError(f"Expected one Xacro input: {args!r}") + return self.xacro.process_file(paths[0], mappings=mappings).toxml() + + @contextmanager + def source(self, name): + self.sources.append(str(name)) + try: + yield + finally: + self.sources.pop() + + def error(self, exception): + self.errors.append( + { + "source": list(self.sources), + "type": type(exception).__name__, + "message": str(exception), + "traceback": "".join(traceback.format_exception(exception, limit=10)), + } + ) + + def capture(self, command, env, cwd, shell, policy): + policy["triggers"] = list(self.triggers) + # Express inheritance without copying unrelated host environment values into reports. + environment = {key: value for key, value in env.items() if self.case_env.get(key) != value} + removed = sorted(self.case_env.keys() - env.keys()) + environment = {"overrides": environment, "removed": removed} + record = self.normalizer.process( + command, + env=environment, + cwd=cwd or str(self.scratch), + shell=shell, + policy=policy, + source=list(self.sources), + ) + self.records.append(record) + program = Path(command[0]).name + offset = ( + 2 if program == "ros2" and len(command) > 1 and command[1] == "launch" else 1 if program == "bl" else None + ) + if offset is not None: + if shell: + raise UnsupportedError("Shell-wrapped nested launches require shell interpretation") + package, filename = command[offset : offset + 2] + args = command[offset + 2 :] + arguments = {} + if program == "ros2": + for arg in args: + key, value = arg.split(":=", 1) + arguments[key] = value + else: + if len(args) % 2: + raise UnsupportedError("Nested better-launch arguments must be option/value pairs") + for key, value in zip(args[::2], args[1::2], strict=True): + if not key.startswith("--"): + raise UnsupportedError(f"Unknown nested launcher option: {key}") + arguments[key[2:]] = value + if package not in self.index.packages: + raise UnsupportedError(f"Nested launch package unavailable in snapshot: {package}") + candidates = list(self.index.packages[package].rglob(filename)) + if len(candidates) != 1: + raise UnsupportedError(f"Cannot uniquely resolve nested launch {package}/{filename}") + boundary = f"launch-process-{len(self.children)}" + record["child_boundary"] = boundary + record["application_command"] = ["", package, filename.removesuffix(".py")] + tracked = self.saved_env.keys() | env.keys() | {"ROBOT_NAME", "ROBOCUP_ROBOT_ID", "ROS_DOMAIN_ID"} + child_env = { + key: env.get(key) + for key in tracked + if self.saved_env.get(key) != env.get(key) or key in {"ROBOT_NAME", "ROBOCUP_ROBOT_ID", "ROS_DOMAIN_ID"} + } + self.children.append( + { + "boundary": boundary, + "request": { + "file": str(candidates[0].relative_to(self.root)), + "arguments": arguments, + "environment": child_env, + }, + } + ) + + def ros_process(self, action, context): + action.prepare(context) + details = action.process_details + output = action._ExecuteLocal__output + if not isinstance(output, dict): + output = self.perform(context, output) + respawn = action._ExecuteLocal__respawn + retries = action._ExecuteLocal__respawn_max_retries if respawn else 0 + policy = { + "output": output, + "respawn_retries": retries, + "respawn_delay": action._ExecuteLocal__respawn_delay if respawn else 0.0, + "emulate_tty": action.emulate_tty, + "lifecycle_target": None, + } + self.capture(details["cmd"], details["env"], details["cwd"], action.shell, policy) + if action._ExecuteLocal__on_exit: + raise UnsupportedError("Process exit callback requires runtime event modeling") + return None + + def walk(self, entities, context): + from launch import LaunchDescription + from launch.actions import IncludeLaunchDescription, TimerAction + from launch.utilities.type_utils import perform_typed_substitution + from launch_ros.actions import ComposableNodeContainer, LoadComposableNodes + + allowed = { + "DeclareLaunchArgument", + "SetLaunchConfiguration", + "GroupAction", + "PushLaunchConfigurations", + "PopLaunchConfigurations", + "ResetLaunchConfigurations", + "PushEnvironment", + "PopEnvironment", + "ResetEnvironment", + "SetEnvironmentVariable", + "UnsetEnvironmentVariable", + "OpaqueFunction", + "LogInfo", + "Log", + "SetParameter", + "SetParametersFromFile", + "SetRemap", + "PushROSNamespace", + } + for entity in entities: + try: + condition = getattr(entity, "condition", None) + if condition is not None and not condition.evaluate(context): + continue + if type(entity) is LaunchDescription: + self.walk(entity.entities, context) + elif type(entity) is IncludeLaunchDescription: + children = entity.execute(context) + with self.source(entity.launch_description_source.location): + self.walk(children or [], context) + elif type(entity) is TimerAction: + delay = perform_typed_substitution(context, entity._TimerAction__period, float) + cancel = perform_typed_substitution(context, entity._TimerAction__cancel_on_shutdown, bool) + self.deferred.append( + ( + delay, + cancel, + list(entity._TimerAction__actions), + dict(context.launch_configurations), + dict(context.environment), + list(self.sources), + ) + ) + elif type(entity) is LoadComposableNodes: + self.components(entity, context) + elif ( + type(entity) in {self.RosNode, self.ExecuteProcess, ComposableNodeContainer} + or type(entity).__name__ in allowed + ): + children = entity.execute(context) + if children: + self.walk(children, context) + else: + raise UnsupportedError(f"Unsupported ROS action: {type(entity).__module__}.{type(entity).__name__}") + except (Exception, UnsafeOperation) as exception: + self.error(exception) + + def components(self, action, context): + from launch.utilities import normalize_to_list_of_substitutions + from launch_ros.actions import ComposableNodeContainer + from launch_ros.actions.load_composable_nodes import get_composable_node_load_request + from rclpy.parameter import parameter_value_to_python + + target = action._LoadComposableNodes__target_container + if isinstance(target, ComposableNodeContainer): + target = target.node_name + else: + target = self.perform(context, normalize_to_list_of_substitutions(target)) + for description in action._LoadComposableNodes__composable_node_descriptions: + request = get_composable_node_load_request(description, context) + if request is None: + continue + self.records.append( + self.normalizer.clean( + { + "kind": "component", + "container": target, + "package": request.package_name, + "plugin": request.plugin_name, + "node_name": request.node_name, + "namespace": request.node_namespace or "/", + "remaps": list(request.remap_rules), + "parameters": { + param.name: parameter_value_to_python(param.value) for param in request.parameters + }, + "extra_arguments": { + param.name: parameter_value_to_python(param.value) for param in request.extra_arguments + }, + "log_level": request.log_level, + "source": list(self.sources), + "triggers": list(self.triggers), + } + ) + ) + if getattr(description, "node_autostart", False): + raise UnsupportedError("Lifecycle component autostart requires event modeling") + + def ros_file(self, file, args, context=None): + from launch import LaunchContext + from launch.actions import IncludeLaunchDescription + from launch.launch_description_sources import AnyLaunchDescriptionSource + + if context is None: + context = LaunchContext() + action = IncludeLaunchDescription(AnyLaunchDescriptionSource(str(file)), launch_arguments=args.items()) + self.walk([action], context) + + def patch_better(self): + import subprocess + + from better_launch.elements import abstract_node + from better_launch.ros import logging as roslog + + self.BL.hello = lambda *_: None + + def unique_name(bl, name="", check_running_nodes=True): + self.unique_counter += 1 + return f"{name}_anonymous_{self.unique_counter}" + + self.BL.get_unique_name = unique_name + self.BL.spin = lambda *_args, **_kwargs: None + self.BL.shutdown = lambda *_args, **_kwargs: None + self.bl_wrapper._init_signal_handlers = lambda: None + self.bl_wrapper.init_logging = lambda *_: None + abstract_node.configure_logger = lambda *_args, **_kwargs: None + roslog.launch_config.log_dir = str(self.scratch / "logs") + self.BNode.is_ros2_connected = lambda *_args, **_kwargs: False + + native_find = self.BL.find + + def find(bl, package=None, filename=None, subdir="**"): + if package == "/lib" or (package == "" and filename): + return filename + if package and package.endswith("/lib"): + return f"package://{package[:-4]}/{filename}" + return native_find(bl, package, filename, subdir) + + self.BL.find = find + self.BL.exec = classmethod(lambda cls, cmd: self.command(shlex.split(cmd) if isinstance(cmd, str) else cmd)) + native_which = shutil.which + shutil.which = ( + lambda command, *args, **kwargs: f"command://{command}" + if command in {"bl", "ros2"} + else native_which(command, *args, **kwargs) + ) + native_node = self.BL.node + signature = inspect.signature(native_node) + + def node(bl, *args, **kwargs): + bound = signature.bind(bl, *args, **kwargs) + bound.apply_defaults() + self.node_options = bound.arguments + if not bound.arguments["autostart_process"]: + raise UnsupportedError("Deferred manual node start is not modeled") + try: + return native_node(bl, *args, **kwargs) + except (Exception, UnsafeOperation) as exception: + self.error(exception) + return None + + self.BL.node = node + + native_start = self.BNode.start + + def start(node): + self.raw_node = node + self.capture_attempted = False + try: + native_start(node) + except Captured: + pass + finally: + if not self.capture_attempted: + self.error( + UnsupportedError( + f"Better-launch did not reach process capture for {node.fullname}; command preparation failed" + ) + ) + self.raw_node = None + + self.BNode.start = start + + def popen(command, **kwargs): + if self.raw_node is None: + raise UnsafeOperation("Unexpected subprocess creation") + self.capture_attempted = True + node = self.raw_node + options = self.node_options + output = options["output"] + if hasattr(output, "name"): + output = output.name.lower() + target = options["lifecycle_target"] + if hasattr(target, "name"): + target = target.name + try: + self.capture( + command, + kwargs["env"], + kwargs["cwd"], + kwargs["shell"], + { + "output": output, + "respawn_retries": node.max_respawns, + "respawn_delay": node.respawn_delay if node.max_respawns else 0.0, + "emulate_tty": False, + "lifecycle_target": None if node.raw else target, + }, + ) + except (Exception, UnsafeOperation) as exception: + self.error(exception) + if options["on_exit"]: + self.error(UnsupportedError("Process exit callback requires runtime event modeling")) + raise Captured() + + subprocess.Popen = popen + + native_include = self.BL.include + + def include(bl, package, launchfile, subdir=None, **kwargs): + with self.source(f"{package}/{launchfile}"): + try: + native_include(bl, package, launchfile, subdir, **kwargs) + except (Exception, UnsafeOperation) as exception: + self.error(exception) + + self.BL.include = include + + def ros2_actions(bl, *actions): + # The actual wrapper shares a LaunchContext among its queued actions. + if self.ros_context is None: + self.ros_context = self.launch.LaunchContext() + self.walk(actions, self.ros_context) + + self.BL.ros2_actions = ros2_actions + + def later(bl, delay, callback, *args, **kwargs): + self.deferred.append((delay, True, (callback, args, kwargs), None, None, list(self.sources))) + return Future() + + self.BL.run_later = later + self.BL.ros2_launch_service = lambda *_args, **_kwargs: self.unsupported("Direct ROS LaunchService access") + self.BL.ros_adapter = property(lambda _: self.unsupported("ROS graph access")) + self.BL.compose = lambda *_args, **_kwargs: self.unsupported("Composable node container") + + @staticmethod + def unsupported(message): + raise UnsupportedError(message) + + def reset(self, request): + os.environ.clear() + os.environ.update(self.saved_env) + for key in ("ROBOT_NAME", "ROBOCUP_ROBOT_ID", "ROS_DOMAIN_ID"): + os.environ.pop(key, None) + for key, value in request.get("environment", {}).items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = str(value) + os.environ["ROS_LOG_DIR"] = str(self.scratch / "logs") + os.environ["BL_UI"] = "false" + self.case_env = dict(os.environ) + self.records, self.errors, self.sources, self.triggers, self.deferred = [], [], [], [], [] + self.unique_counter = 0 + self.children = [] + self.ros_context = None + if self.engine == "better": + self.bl_module.__dict__.pop("__better_launch_instance", None) + self.bl_module.BetterLaunchMeta._singleton_future = Future() + self.BL._launchfile = None + self.BL._launch_func_args = {} + self.bl_settings._SETTINGS = self.bl_settings._Settings() + replacements = [ + (str(self.root), ""), + (str(self.scratch), ""), + (str(Path(sys.prefix)), ""), + ] + for package, source in self.index.packages.items(): + replacements.extend( + [ + (str(source), f"package-share://{package}"), + (str(self.scratch / "packages" / package / "share" / package), f"package-share://{package}"), + ] + ) + replacements.append((str(Path.home()), "")) + if not hasattr(self, "normalizer"): + self.normalizer = Normalizer(replacements) + + def evaluate(self, request): + self.reset(request) + path = self.root / request["file"] + args = request.get("arguments", {}) + stream = io.StringIO() + with redirect_stdout(stream), redirect_stderr(stream), self.source(path): + try: + if self.engine == "ros": + self.ros_file(path, args) + else: + sys.argv = [str(path)] + [word for name, value in args.items() for word in (f"--{name}", value)] + runpy.run_path(str(path), run_name="__main__") + pending, self.deferred = self.deferred, [] + for delay, cancel, action, configurations, environment, sources in pending: + self.sources = sources + self.triggers = [{"after_seconds": delay, "cancel_on_shutdown": cancel}] + if configurations is None: + callback, callback_args, callback_kwargs = action + callback(*callback_args, **callback_kwargs) + else: + context = self.launch.LaunchContext() + context.launch_configurations.update(configurations) + context.environment.clear() + context.environment.update(environment) + self.walk(action, context) + if self.deferred: + self.error(UnsupportedError("Nested timers require event modeling")) + except (Exception, UnsafeOperation, SystemExit) as exception: + self.error(exception) + for share in self.index.used.values(): + for path in share.rglob("*"): + if path.is_file() and not path.is_symlink(): + self.records.append( + { + "kind": "generated_file", + "path": self.normalizer.clean(str(path)), + "content": path.read_text(), + } + ) + path.unlink() + # A missing native name prevents a sound choice of node-specific YAML selectors. + for record in self.records: + if record.get("unresolved_parameter_selectors"): + self.error( + UnsupportedError( + f"Cannot resolve parameter selectors without the executable's default node name: {record.get('application_command')}" + ) + ) + return { + "records": self.records, + "errors": self.normalizer.clean(self.errors), + "log": self.normalizer.clean(stream.getvalue()[-20000:]), + "versions": self.native_versions, + "children": self.children, + "cache": { + "unique_processes": len(self.normalizer.process_cache), + "reused_processes": self.normalizer.process_hits, + }, + } + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--scratch", type=Path, required=True) + parser.add_argument("--engine", choices=["ros", "better"], required=True) + options = parser.parse_args() + options.scratch.mkdir(parents=True, exist_ok=True) + (options.scratch / "logs").mkdir(exist_ok=True) + tempfile.tempdir = str(options.scratch) + os.chdir(options.scratch) + # Freeze only the source of wall-clock timestamps, not their formatting or use. + original_datetime = datetime.datetime + + class FrozenDatetime(original_datetime): + @classmethod + def now(cls, tz=None): + return cls.fromtimestamp(0, tz) + + datetime.datetime = FrozenDatetime + evaluator = Evaluator(options.root, options.scratch, options.engine) + contain(options.scratch) + logging.disable(logging.CRITICAL) + print(json.dumps({"ready": True}), flush=True) + for line in sys.stdin: + try: + result = evaluator.evaluate(json.loads(line)) + except BaseException as exception: + result = { + "records": [], + "errors": [{"type": type(exception).__name__, "message": str(exception)}], + "log": traceback.format_exc(), + } + print(json.dumps(result), flush=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/ros.plugin.sh b/scripts/ros.plugin.sh index f28f144c32..24902dd50c 100755 --- a/scripts/ros.plugin.sh +++ b/scripts/ros.plugin.sh @@ -46,8 +46,9 @@ setup_alises() { # ros aliases alias ros2='cdc && pixi run ros2' + alias bl='cdc && pixi run bl' alias rr='ros2 run' - alias rl='ros2 launch' + alias rl='bl' alias rte='ros2 topic echo' alias rtl='ros2 topic list' @@ -81,8 +82,9 @@ setup_alises() { # ros aliases unalias ros2 + unalias bl alias rr='ros2 run' - alias rl='ros2 launch' + alias rl='bl' alias rte='ros2 topic echo' alias rtl='ros2 topic list' diff --git a/src/bitbots_behavior/bitbots_body_behavior/launch/behavior.launch b/src/bitbots_behavior/bitbots_body_behavior/launch/behavior.launch deleted file mode 100644 index b1c0d5ce42..0000000000 --- a/src/bitbots_behavior/bitbots_body_behavior/launch/behavior.launch +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/src/bitbots_behavior/bitbots_body_behavior/launch/behavior.launch.py b/src/bitbots_behavior/bitbots_body_behavior/launch/behavior.launch.py new file mode 100644 index 0000000000..6f5f13374a --- /dev/null +++ b/src/bitbots_behavior/bitbots_body_behavior/launch/behavior.launch.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def behavior(dsd_file: str = "main.dsd", tf_prefix: str = "", sim: bool = False): + bl = BetterLaunch() + + bl.node( + "bitbots_body_behavior", + "body_behavior", + "body_behavior", + params={ + "dsd_file": dsd_file, + "actionlib_server_sub_queue_size": -1, + "odom_frame": f"{tf_prefix}odom", + "map_frame": f"{tf_prefix}map", + "base_footprint_frame": f"{tf_prefix}base_footprint", + }, + param_files=[ + bl.find("bitbots_body_behavior", "body_behavior.yaml", "config"), + bl.find("bitbots_body_behavior", "animations.yaml", "config"), + ], + use_sim_time=sim, + max_respawns=-1, + ) diff --git a/src/bitbots_behavior/bitbots_body_behavior/launch/behavior_standalone.launch b/src/bitbots_behavior/bitbots_body_behavior/launch/behavior_standalone.launch deleted file mode 100644 index d006d1211f..0000000000 --- a/src/bitbots_behavior/bitbots_body_behavior/launch/behavior_standalone.launch +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/src/bitbots_behavior/bitbots_body_behavior/launch/behavior_standalone.launch.py b/src/bitbots_behavior/bitbots_body_behavior/launch/behavior_standalone.launch.py new file mode 100644 index 0000000000..da9458f7b0 --- /dev/null +++ b/src/bitbots_behavior/bitbots_body_behavior/launch/behavior_standalone.launch.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def behavior_standalone(sim: bool = False, dsd_file: str = "main.dsd"): + """ + Parameters + ---------- + dsd_file : str + The behavior dsd file that should be used + """ + bl = BetterLaunch() + + fieldname = "small_division_2026" if sim else "labor" + + bl.include( + "bitbots_parameter_blackboard", + "parameter_blackboard.launch.py", + sim=sim, + fieldname=fieldname, + ) + + bl.include( + "bitbots_body_behavior", + "behavior.launch.py", + dsd_file=dsd_file, + sim=sim, + ) diff --git a/src/bitbots_behavior/bitbots_body_behavior/setup.py b/src/bitbots_behavior/bitbots_body_behavior/setup.py index 097ec5c5bd..ef540b3d8e 100644 --- a/src/bitbots_behavior/bitbots_body_behavior/setup.py +++ b/src/bitbots_behavior/bitbots_body_behavior/setup.py @@ -12,7 +12,7 @@ ("share/" + package_name, ["package.xml"]), ("share/ament_index/resource_index/packages", ["resource/" + package_name]), ("share/" + package_name + "/config", glob.glob("config/*")), - ("share/" + package_name + "/launch", glob.glob("launch/*.launch")), + ("share/" + package_name + "/launch", glob.glob("launch/*.launch.py")), ], scripts=[], install_requires=[ diff --git a/src/bitbots_misc/bitbots_bringup/launch/audio.launch b/src/bitbots_misc/bitbots_bringup/launch/audio.launch deleted file mode 100644 index 8e2320fc40..0000000000 --- a/src/bitbots_misc/bitbots_bringup/launch/audio.launch +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_bringup/launch/audio.launch.py b/src/bitbots_misc/bitbots_bringup/launch/audio.launch.py new file mode 100644 index 0000000000..abb7f85465 --- /dev/null +++ b/src/bitbots_misc/bitbots_bringup/launch/audio.launch.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def audio(sample_rate: int = 48000): + """ + Parameters + ---------- + sample_rate : int + The sample_rate with which the audio should be captured. Our code currently is not + sample_rate agnostic, but this must be overwritable to start the audio_capturer_node + on some audio drivers/devices if they do not support the default sample_rate of 16000. + """ + bl = BetterLaunch() + bl.node( + "audio_common", + "audio_capturer_node", + "audio_capturer_node", + params={"rate": sample_rate}, + ) diff --git a/src/bitbots_misc/bitbots_bringup/launch/demo.launch b/src/bitbots_misc/bitbots_bringup/launch/demo.launch deleted file mode 100644 index 63ead4f5e9..0000000000 --- a/src/bitbots_misc/bitbots_bringup/launch/demo.launch +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_bringup/launch/demo.launch.py b/src/bitbots_misc/bitbots_bringup/launch/demo.launch.py new file mode 100644 index 0000000000..72632e0dcf --- /dev/null +++ b/src/bitbots_misc/bitbots_bringup/launch/demo.launch.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def demo(sim: bool = False, behavior_dsd_file: str = "demo.dsd"): + """ + Parameters + ---------- + sim : bool + Whether the robot is running in simulation or on real hardware + behavior_dsd_file : str + The behavior dsd file that should be used + """ + bl = BetterLaunch() + + # load teamplayer software stack without some unnecessary stuff, that is not needed in the demo + bl.include( + "bitbots_bringup", + "teamplayer.launch.py", + behavior_dsd_file=behavior_dsd_file, + fieldname="demo", + game_controller=False, + localization=False, + sim=sim, + teamcom=False, + path_planning=True, + ) diff --git a/src/bitbots_misc/bitbots_bringup/launch/highlevel.launch b/src/bitbots_misc/bitbots_bringup/launch/highlevel.launch deleted file mode 100644 index bd2f8208f2..0000000000 --- a/src/bitbots_misc/bitbots_bringup/launch/highlevel.launch +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_bringup/launch/highlevel.launch.py b/src/bitbots_misc/bitbots_bringup/launch/highlevel.launch.py new file mode 100644 index 0000000000..accf2adcfe --- /dev/null +++ b/src/bitbots_misc/bitbots_bringup/launch/highlevel.launch.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def highlevel( + audio: bool = True, + behavior_dsd_file: str = "main.dsd", + behavior: bool = True, + game_controller: bool = True, + ipm: bool = True, + localization: bool = True, + path_planning: bool = True, + sim: bool = False, + teamcom: bool = False, + vision: bool = True, + workspace_status: bool = True, + world_model: bool = True, + whistle_detector: bool = True, +): + """ + Parameters + ---------- + audio : bool + Whether the audio system should be started + behavior_dsd_file : str + The behavior dsd file that should be used + behavior : bool + Whether the behavior control system should be started + game_controller : bool + Whether the Gamecontroller module should be started + ipm : bool + Whether the inverse perspective mapping should be started + localization : bool + Whether the localization system should be started + path_planning : bool + Whether the path planning should be started + sim : bool + Whether the robot is running in simulation or on real hardware + teamcom : bool + Whether the team communication system should be started + vision : bool + Whether the vision system should be started + workspace_status : bool + Whether to publish the current workspace status + world_model : bool + Whether the world model should be started + whistle_detector : bool + Whether whistle detector should be started + """ + bl = BetterLaunch() + + # launch game controller + if game_controller: + bl.include( + "game_controller_hsl", + "game_controller.launch", + sim=sim, + use_parameter_blackboard=True, + parameter_blackboard_name="parameter_blackboard", + team_id_param_name="team_id", + bot_id_param_name="bot_id", + ) + bl.include("bitbots_player_state", "player_state_aggregator.launch.py", sim=sim) + + # launch vision + if vision: + bl.include("bitbots_bringup", "vision.launch.py", sim=sim) + + # launch inverse perspective mapping (ipm) + if ipm: + bl.include("bitbots_ipm", "ipm.launch.py", sim=sim) + + # launch teamcom + if teamcom: + bl.include("bitbots_team_communication", "team_comm.launch.py", sim=sim) + + # launch world model + if world_model: + bl.include("bitbots_ball_filter", "ball_filter.launch.py", sim=sim) + bl.include("bitbots_robot_filter", "robot_filter.launch.py", sim=sim) + + if whistle_detector: + bl.include("bitbots_whistle_detector", "whistle_detector.launch.py", sim=sim) + + # launch localization or fake localization + if localization: + bl.include("bitbots_localization", "localization.launch.py", sim=sim) + else: + # simulate map frame + bl.node( + "tf2_ros", + "static_transform_publisher", + "map_odom", + cmd_args=[ + "--x", + "0", + "--y", + "0", + "--z", + "0", + "--roll", + "0", + "--pitch", + "0", + "--yaw", + "0", + "--frame-id", + "map", + "--child-frame-id", + "odom", + ], + log_level=None, + ) + # publish perfect covariance + # bl.node("bitbots_localization", "rviz_localization_sim.py", "localization_covariance") + + # launch path planning + if path_planning: + bl.include("bitbots_path_planning", "path_planning.launch.py", sim=sim) + + # launch behavior + if behavior: + bl.include( + "bitbots_body_behavior", + "behavior.launch.py", + dsd_file=behavior_dsd_file, + sim=sim, + ) + + # launch audio processing + if audio: + bl.include("bitbots_bringup", "audio.launch.py") + + # launch workspace status publisher + if workspace_status and not sim: + bl.node( + "bitbots_utils", + "publish_workspace_status.py", + "WorkspaceStatusPublisher", + params={ + "workspace_status_path": bl.find("bitbots_utils", "workspace_status.json", "config"), + "publish_topic": "/workspace_status", + }, + ) diff --git a/src/bitbots_misc/bitbots_bringup/launch/monitoring.launch b/src/bitbots_misc/bitbots_bringup/launch/monitoring.launch deleted file mode 100644 index 0c943b053b..0000000000 --- a/src/bitbots_misc/bitbots_bringup/launch/monitoring.launch +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/bitbots_misc/bitbots_bringup/launch/monitoring.launch.py b/src/bitbots_misc/bitbots_bringup/launch/monitoring.launch.py new file mode 100644 index 0000000000..3617ac1f1b --- /dev/null +++ b/src/bitbots_misc/bitbots_bringup/launch/monitoring.launch.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def monitoring(): + bl = BetterLaunch() + + bl.include("udp_bridge", "send.launch") + bl.include("system_monitor", "system_monitor.launch.py", required=False) diff --git a/src/bitbots_misc/bitbots_bringup/launch/monitoring_pc.launch b/src/bitbots_misc/bitbots_bringup/launch/monitoring_pc.launch deleted file mode 100644 index 4af3b2f071..0000000000 --- a/src/bitbots_misc/bitbots_bringup/launch/monitoring_pc.launch +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_bringup/launch/monitoring_pc.launch.py b/src/bitbots_misc/bitbots_bringup/launch/monitoring_pc.launch.py new file mode 100644 index 0000000000..98da3d5138 --- /dev/null +++ b/src/bitbots_misc/bitbots_bringup/launch/monitoring_pc.launch.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def monitoring_pc(): + bl = BetterLaunch() + + # start udp bridge client to listen to the robot + bl.include("udp_bridge", "receive.launch") + + # start foxglove bridge + bl.include("foxglove_bridge", "foxglove_bridge_launch.xml") + + # start foxglove gui + bl.process(["lichtblick", "--no-sandbox"], name="lichtblick", output="screen") + + # start dynamic_stack_decider_visualization dsd_gui + bl.node("dynamic_stack_decider_visualization", "dsd_gui", "dsd_gui") diff --git a/src/bitbots_misc/bitbots_bringup/launch/motion.launch b/src/bitbots_misc/bitbots_bringup/launch/motion.launch deleted file mode 100644 index 725a9b59b0..0000000000 --- a/src/bitbots_misc/bitbots_bringup/launch/motion.launch +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_bringup/launch/motion.launch.py b/src/bitbots_misc/bitbots_bringup/launch/motion.launch.py new file mode 100644 index 0000000000..c1d3b9cf70 --- /dev/null +++ b/src/bitbots_misc/bitbots_bringup/launch/motion.launch.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def motion( + sim: bool = False, + viz: bool = False, + torqueless_mode: bool = False, + rl_motion: bool = True, +): + """ + Parameters + ---------- + torqueless_mode : bool + start without torque, for example for testing the falling detection + rl_motion : bool + Whether to use the RL motion system instead of the regular one + """ + bl = BetterLaunch() + + # launch the hardware interface + if not sim: + bl.include("livelybot_bringup", "lowlevel.launch") + + # launch the base footprint + bl.node( + "humanoid_base_footprint", + "base_footprint", + "base_footprint", + params={"support_state_topics": ["walk_support_state"]}, + use_sim_time=sim, + ) + + # launch the odometry + bl.include("bitbots_odometry", "odometry.launch.py", sim=sim) + + # launch the animation server + bl.include("bitbots_animation_server", "animation.launch.py", sim=sim) + + # launch the head mover + bl.include("bitbots_head_mover", "head_mover.launch.py", sim=sim) + + if rl_motion: + bl.include("bitbots_rl_motion", "rl_motion.launch.py", sim=sim) + + # launch the hcm + bl.include("bitbots_hcm", "hcm.launch.py", sim=sim, viz=viz) diff --git a/src/bitbots_misc/bitbots_bringup/launch/motion_standalone.launch b/src/bitbots_misc/bitbots_bringup/launch/motion_standalone.launch deleted file mode 100644 index 3a485c9670..0000000000 --- a/src/bitbots_misc/bitbots_bringup/launch/motion_standalone.launch +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_bringup/launch/motion_standalone.launch.py b/src/bitbots_misc/bitbots_bringup/launch/motion_standalone.launch.py new file mode 100644 index 0000000000..2c88d64827 --- /dev/null +++ b/src/bitbots_misc/bitbots_bringup/launch/motion_standalone.launch.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def motion_standalone( + sim: bool = False, + viz: bool = False, + torqueless_mode: bool = False, + tts: bool = True, +): + """ + Parameters + ---------- + torqueless_mode : bool + start without torque, for example for testing the falling detection + tts : bool + Whether to enable text-to-speech + """ + bl = BetterLaunch() + + bl.include("bitbots_parameter_blackboard", "parameter_blackboard.launch.py", sim=sim) + bl.include("bitbots_robot_description", "load_robot_description.launch.py", sim=sim) + + if tts: + bl.include("bitbots_tts", "tts.launch.py") + + if viz: + bl.node("bitbots_utils", "motor_goals_viz_helper.py", "MotorGoalsVizHelper", cmd_args=["--all"]) + bl.node("rviz2", "rviz2") + bl.node("bitbots_utils", "dummy_imu.py", "DummyImu") + + bl.include( + "bitbots_bringup", + "motion.launch.py", + sim=sim, + viz=viz, + torqueless_mode=torqueless_mode, + ) diff --git a/src/bitbots_misc/bitbots_bringup/launch/mujoco_simulation.launch.py b/src/bitbots_misc/bitbots_bringup/launch/mujoco_simulation.launch.py index f2ac1aa5a4..28f485b35c 100644 --- a/src/bitbots_misc/bitbots_bringup/launch/mujoco_simulation.launch.py +++ b/src/bitbots_misc/bitbots_bringup/launch/mujoco_simulation.launch.py @@ -1,18 +1,9 @@ +#!/usr/bin/env python3 import os from pathlib import Path import yaml -from ament_index_python.packages import get_package_share_directory -from launch import LaunchDescription -from launch.actions import ( - DeclareLaunchArgument, - ExecuteProcess, - LogInfo, - OpaqueFunction, - TimerAction, -) -from launch.substitutions import LaunchConfiguration -from launch_ros.actions import Node +from better_launch import BetterLaunch, launch_this # Teamplayer arguments to expose (name, description) # Empty string default means "not set" - will use teamplayer's default @@ -111,113 +102,93 @@ def generate_world_xml(num_robots: int, package_share: str, robot_type: str) -> return output_path -def launch_setup(context): - """Dynamically set up launches based on num_robots.""" - num_robots = int(LaunchConfiguration("num_robots").perform(context)) - robot_type = str(LaunchConfiguration("robot_type").perform(context)) - use_web = LaunchConfiguration("web").perform(context).lower() == "true" - package_share = get_package_share_directory("bitbots_mujoco_sim") +@launch_this +def mujoco_simulation( + num_robots: int = 1, + robot_type: str = "piplus", + web: bool = True, + audio: str = "", + behavior: str = "", + behavior_dsd_file: str = "", + game_controller: str = "", + ipm: str = "", + localization: str = "", + motion: str = "", + path_planning: str = "", + teamcom: str = "", + vision: str = "", + world_model: str = "", + monitoring: str = "", + record: str = "", + tts: str = "", +): + """Launch MuJoCo simulation with domain bridge for multi-robot support. + + Parameters + ---------- + num_robots : int + Number of robots in the simulation + robot_type : str + Set the type of robot used (piplus, x02) + web : bool + Use web-based mjviser viewer instead of the native MuJoCo viewer + """ + bl = BetterLaunch() + + # Only forwarded to the per-robot teamplayer stack if explicitly set (not empty) + teamplayer_values = { + "audio": audio, + "behavior": behavior, + "behavior_dsd_file": behavior_dsd_file, + "game_controller": game_controller, + "ipm": ipm, + "localization": localization, + "motion": motion, + "path_planning": path_planning, + "teamcom": teamcom, + "vision": vision, + "world_model": world_model, + "monitoring": monitoring, + "record": record, + "tts": tts, + } + + package_share = Path(bl.find("bitbots_mujoco_sim")) / "share" / "bitbots_mujoco_sim" bridge_config_dir = Path(package_share) / "config" / "domain_bridges" - # Get teamplayer argument values - only pass if explicitly set (not empty) - teamplayer_args = ["sim:=true"] # sim is always true for mujoco simulation - for arg_name, _ in TEAMPLAYER_ARGS: - value = LaunchConfiguration(arg_name).perform(context) + teamplayer_args = ["--sim", "true"] # sim is always true for mujoco simulation + for arg_name, value in teamplayer_values.items(): if value: # Only pass if not empty string - teamplayer_args.append(f"{arg_name}:={value}") + teamplayer_args += [f"--{arg_name}", value] world_file = generate_world_xml(num_robots, package_share, robot_type) - actions = [] - - actions.append( - LogInfo(msg=f"Starting MuJoCo simulation with {num_robots} robot(s)"), - ) - actions.append( - Node( - package="bitbots_mujoco_sim", - executable="sim", - name="sim_interface", - output="screen", - emulate_tty=True, - parameters=[{"world_file": str(world_file), "web": use_web}], - ), + bl.logger.info(f"Starting MuJoCo simulation with {num_robots} robot(s)") + bl.node( + "bitbots_mujoco_sim", + "sim", + "sim_interface", + params={"world_file": str(world_file), "web": web}, ) - for robot_domain in range(11, num_robots + 11): # 11 is the standart starting id for our robots + for robot_domain in range(11, num_robots + 11): # 11 is the standard starting id for our robots config_file = generate_domain_bridge_config(robot_domain, bridge_config_dir) - actions.append( - LogInfo(msg=f"Starting domain bridge for robot{robot_domain} (domain {robot_domain})"), - ) - actions.append( - Node( - package="domain_bridge", - executable="domain_bridge", - name=f"domain_bridge_robot{robot_domain}", - arguments=[str(config_file)], - output="screen", - emulate_tty=True, - ), - ) - actions.append( - TimerAction( - period=3.0, - actions=[ - LogInfo(msg=f"Launching teamplayer stack for robot{robot_domain} in domain {robot_domain}"), - ExecuteProcess( - cmd=[ - "ros2", - "launch", - "bitbots_bringup", - "teamplayer.launch", - ] - + teamplayer_args, - output="screen", - additional_env={"ROS_DOMAIN_ID": str(robot_domain)}, - ), - ], - ) + bl.logger.info(f"Starting domain bridge for robot{robot_domain} (domain {robot_domain})") + bl.node( + "domain_bridge", + "domain_bridge", + f"domain_bridge_robot{robot_domain}", + cmd_args=[str(config_file)], ) - return actions - - -def generate_launch_description(): - """Launch MuJoCo simulation with domain bridge for multi-robot support.""" - - declared_args = [ - DeclareLaunchArgument( - "num_robots", - default_value="1", - description="Number of robots in the simulation", - ), - DeclareLaunchArgument( - "robot_type", - default_value="piplus", - description="Set the type of robot used (piplus, x02)", - ), - DeclareLaunchArgument( - "web", - default_value="true", - description="Use web-based mjviser viewer instead of the native MuJoCo viewer", - ), - ] - - # Add all teamplayer arguments with empty default (means use teamplayer's default) - for arg_name, description in TEAMPLAYER_ARGS: - declared_args.append( - DeclareLaunchArgument( - arg_name, - default_value="", - description=description, + def start_teamplayer(robot_domain=robot_domain): + bl.logger.info(f"Launching teamplayer stack for robot{robot_domain} in domain {robot_domain}") + bl.process( + ["bl", "bitbots_bringup", "teamplayer.launch.py"] + teamplayer_args, + name=f"teamplayer_robot{robot_domain}", + output="screen", + env={"ROS_DOMAIN_ID": str(robot_domain)}, ) - ) - return LaunchDescription( - declared_args - + [ - # All setup happens in OpaqueFunction to ensure proper ordering - OpaqueFunction(function=launch_setup), - ] - ) + bl.run_later(3.0, start_teamplayer) diff --git a/src/bitbots_misc/bitbots_bringup/launch/rosbag_record.launch.py b/src/bitbots_misc/bitbots_bringup/launch/rosbag_record.launch.py index 2f84cccba7..c61b311140 100644 --- a/src/bitbots_misc/bitbots_bringup/launch/rosbag_record.launch.py +++ b/src/bitbots_misc/bitbots_bringup/launch/rosbag_record.launch.py @@ -1,11 +1,8 @@ +#!/usr/bin/env python3 import os from datetime import datetime -from launch import LaunchDescription -from launch.actions import DeclareLaunchArgument, ExecuteProcess, OpaqueFunction -from launch.substitutions import EnvironmentVariable, LaunchConfiguration, PathJoinSubstitution - -# from launch_ros.actions import Node +from better_launch import BetterLaunch, launch_this TOPICS_TO_RECORD: list[str] = [ "/animation", @@ -65,83 +62,45 @@ ] -def generate_launch_arguments(): - return [ - DeclareLaunchArgument( - "sim", default_value="false", description="true: Use simulation time", choices=["true", "false"] - ), - DeclareLaunchArgument( - "max_image_frequency", default_value="1.0", description="Max frequency [hz] for recording images" - ), - ] - - -def generate_nodes(): - return [ - # Node( - # package="topic_tools", - # executable="throttle", - # output="screen", - # name="record_rosbag_drop_images", - # arguments=[ - # "messages", - # "/zed/zed_node/rgb/image_rect_color", - # LaunchConfiguration("max_image_frequency"), - # "/camera/image_to_record", - # ], - # ) - ] - +@launch_this +def rosbag_record(sim: bool = False, max_image_frequency: float = 1.0): + """ + Parameters + ---------- + sim : bool + Use simulation time + max_image_frequency : float + Max frequency [hz] for recording images + """ + bl = BetterLaunch() -def generate_action(context): - robot_name = os.getenv("ROBOCUP_ROBOT_ID", default=os.getenv("ROBOT_NAME", default="unknown_robot")) + robot_name = os.getenv("ROBOCUP_ROBOT_ID", os.getenv("ROBOT_NAME", "unknown_robot")) # Set output directory # ~/rosbags/ID__ - output_directory = PathJoinSubstitution( - [ - EnvironmentVariable("HOME"), - "rosbags", - "ID_" + robot_name + "_" + datetime.now().isoformat(timespec="seconds"), - ] + output_directory = os.path.join( + os.environ["HOME"], + "rosbags", + f"ID_{robot_name}_{datetime.now().isoformat(timespec='seconds')}", ) - sim_value = LaunchConfiguration("sim").perform(context) - sim_time = ["--use-sim-time"] if sim_value == "true" else [] - - node_name = "ros2_bag_record" - - main_process = ExecuteProcess( - # Constructing the complete command - cmd=[ - # Main command to start recording ros2 bags - "ros2", - "bag", - "record", - "-o", - output_directory, - # Other options - "--node-name", - node_name, - "--include-hidden-topics", - "--include-unpublished-topics", - "--polling-interval", - "1000", - ] - + sim_time - + TOPICS_TO_RECORD, - output="screen", - name=node_name, - shell=True, - ) - return [main_process] - + cmd = [ + "ros2", + "bag", + "record", + "-o", + output_directory, + "--node-name", + "ros2_bag_record", + "--include-hidden-topics", + "--include-unpublished-topics", + "--polling-interval", + "1000", + ] -def generate_launch_description(): - launch_arguments = generate_launch_arguments() - nodes = generate_nodes() + if sim: + cmd.append("--use-sim-time") - action = OpaqueFunction(function=generate_action) + cmd.extend(TOPICS_TO_RECORD) - # Construct LaunchDescription from parts - return LaunchDescription(launch_arguments + nodes + [action]) + bl.process(cmd, name="ros2_bag_record", output="screen", use_shell=True) diff --git a/src/bitbots_misc/bitbots_bringup/launch/simulator_teamplayer.launch b/src/bitbots_misc/bitbots_bringup/launch/simulator_teamplayer.launch deleted file mode 100644 index e29c13aab2..0000000000 --- a/src/bitbots_misc/bitbots_bringup/launch/simulator_teamplayer.launch +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_bringup/launch/simulator_teamplayer.launch.py b/src/bitbots_misc/bitbots_bringup/launch/simulator_teamplayer.launch.py new file mode 100644 index 0000000000..f16b93f8a9 --- /dev/null +++ b/src/bitbots_misc/bitbots_bringup/launch/simulator_teamplayer.launch.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def simulator_teamplayer( + audio: bool = False, + behavior: bool = True, + behavior_dsd_file: str = "main.dsd", + game_controller: bool = True, + ipm: bool = True, + localization: bool = True, + motion: bool = True, + path_planning: bool = True, + teamcom: bool = False, + vision: bool = True, + world_model: bool = True, + rl_motion: bool = True, + web: bool = True, +): + """ + Parameters + ---------- + audio : bool + Whether the audio system should be started + behavior : bool + Whether the behavior control system should be started + behavior_dsd_file : str + The behavior dsd file that should be used + game_controller : bool + Whether the Gamecontroller module should be started + ipm : bool + Whether the inverse perspective mapping should be started + localization : bool + Whether the localization system should be started + motion : bool + Whether the motion control system should be started + path_planning : bool + Whether the path planning should be started + teamcom : bool + Whether the team communication system should be started + vision : bool + Whether the vision system should be started + world_model : bool + Whether the world model should be started + rl_motion : bool + Whether to use the RL motion system instead of the regular one + web : bool + Use web-based mjviser viewer instead of the native MuJoCo viewer + """ + bl = BetterLaunch(pass_launch_func_default=False) + + # load the general simulator + bl.include("bitbots_mujoco_sim", "simulator.launch.py", web=web) + + # load teamplayer software stack + bl.include( + "bitbots_bringup", + "teamplayer.launch.py", + audio=audio, + behavior=behavior, + behavior_dsd_file=behavior_dsd_file, + game_controller=game_controller, + ipm=ipm, + localization=localization, + motion=motion, + sim=True, + path_planning=path_planning, + teamcom=teamcom, + vision=vision, + world_model=world_model, + rl_motion=rl_motion, + ) diff --git a/src/bitbots_misc/bitbots_bringup/launch/teamplayer.launch b/src/bitbots_misc/bitbots_bringup/launch/teamplayer.launch deleted file mode 100644 index e03ee79508..0000000000 --- a/src/bitbots_misc/bitbots_bringup/launch/teamplayer.launch +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_bringup/launch/teamplayer.launch.py b/src/bitbots_misc/bitbots_bringup/launch/teamplayer.launch.py new file mode 100644 index 0000000000..f228b24bd5 --- /dev/null +++ b/src/bitbots_misc/bitbots_bringup/launch/teamplayer.launch.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def teamplayer( + audio: bool = True, + behavior: bool = True, + behavior_dsd_file: str = "main.dsd", + game_controller: bool = True, + ipm: bool = True, + localization: bool = True, + motion: bool = True, + path_planning: bool = True, + sim: bool = False, + teamcom: bool = True, + vision: bool = True, + world_model: bool = True, + monitoring: bool = False, + record: bool = False, + tts: bool = False, + whistle_detector: bool = True, + fieldname: str = None, +): + """ + Parameters + ---------- + audio : bool + Whether the audio system should be started + behavior : bool + Whether the behavior control system should be started + behavior_dsd_file : str + The behavior dsd file that should be used + game_controller : bool + Whether the Gamecontroller module should be started + ipm : bool + Whether the inverse perspective mapping should be started + localization : bool + Whether the localization system should be started + motion : bool + Whether the motion control system should be started + path_planning : bool + Whether the path planning should be started + sim : bool + Whether the robot is running in simulation or on real hardware + teamcom : bool + Whether the team communication system should be started + vision : bool + Whether the vision system should be started + world_model : bool + Whether the world model should be started + monitoring : bool + Whether the system monitor and udp bridge should be started + record : bool + Whether the ros bag recording should be started + tts : bool + Whether to speak + whistle_detector : bool + Whether to detect whistles + fieldname : str + Loads field settings. Defaults to "hsl_kid" in simulation, "small_division_2026" otherwise. + """ + bl = BetterLaunch(pass_launch_func_default=False) + + # TODO: use better_launch's own use_sim_time mechanism (bl.group(use_sim_time=...) / + # Settings().use_sim_time / BL_USE_SIM_TIME) instead of manually threading `sim` through + # every node()/include() call across all our launch files. Before switching, check how + # that propagates into included *regular ROS2* launch files (game_controller_hsl, + # zed_wrapper, livelybot_bringup, domain_bridge, udp_bridge, foxglove_bridge, + # rosbridge_server) - those run via a separate ROS2 LaunchService and don't share our + # group stack, so they'd likely still need sim/use_sim_time passed explicitly. + + if fieldname is None: + fieldname = "hsl_kid" if sim else "ifa26" + + # load the global parameters + bl.include("bitbots_parameter_blackboard", "parameter_blackboard.launch.py", sim=sim, fieldname=fieldname) + + # load the text to speech engine + if tts: + bl.include("bitbots_tts", "tts.launch.py") + + # load the diagnostic aggregator + bl.include("bitbots_diagnostic", "aggregator.launch.py") + + # load the robot description + bl.include("bitbots_robot_description", "load_robot_description.launch.py", sim=sim) + + # load the motion + if motion: + bl.include("bitbots_bringup", "motion.launch.py", sim=sim) + + # load the highlevel stuff + bl.include( + "bitbots_bringup", + "highlevel.launch.py", + audio=audio, + behavior=behavior, + behavior_dsd_file=behavior_dsd_file, + game_controller=game_controller, + ipm=ipm, + localization=localization, + path_planning=path_planning, + sim=sim, + teamcom=teamcom, + vision=vision, + world_model=world_model, + whistle_detector=whistle_detector, + ) + + # load monitoring + if monitoring and not sim: + bl.include("bitbots_bringup", "monitoring.launch.py") + + # record rosbag + if record: + bl.include("bitbots_bringup", "rosbag_record.launch.py", sim=sim) diff --git a/src/bitbots_misc/bitbots_bringup/launch/vision.launch b/src/bitbots_misc/bitbots_bringup/launch/vision.launch deleted file mode 100644 index 5e78ca5887..0000000000 --- a/src/bitbots_misc/bitbots_bringup/launch/vision.launch +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_bringup/launch/vision.launch.py b/src/bitbots_misc/bitbots_bringup/launch/vision.launch.py new file mode 100644 index 0000000000..300664f74d --- /dev/null +++ b/src/bitbots_misc/bitbots_bringup/launch/vision.launch.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def vision(sim: bool = False, camera: bool = True, debug: bool = False): + """ + Parameters + ---------- + sim : bool + true: activates simulation time, switches to simulation color settings and deactivates + launching of an image provider + camera : bool + true: launches an image provider to get images from a camera (unless sim:=true) + debug : bool + true: activates publishing of several debug images + """ + bl = BetterLaunch() + + # Start the vision + bl.include("bitbots_vision", "vision.launch.py", sim=sim, debug=debug) + + # Start the camera only when necessary + if camera and not sim: + bl.include( + "zed_wrapper", + "zed_camera.launch.py", + camera_model="zedm", + publish_urdf=False, + ) diff --git a/src/bitbots_misc/bitbots_bringup/launch/vision_standalone.launch b/src/bitbots_misc/bitbots_bringup/launch/vision_standalone.launch deleted file mode 100644 index e2b6e71b75..0000000000 --- a/src/bitbots_misc/bitbots_bringup/launch/vision_standalone.launch +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_bringup/launch/vision_standalone.launch.py b/src/bitbots_misc/bitbots_bringup/launch/vision_standalone.launch.py new file mode 100644 index 0000000000..253ce896bd --- /dev/null +++ b/src/bitbots_misc/bitbots_bringup/launch/vision_standalone.launch.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def vision_standalone(sim: bool = False, camera: bool = True, debug: bool = False, fieldname: str = None): + """ + Parameters + ---------- + sim : bool + true: activates simulation time, switches to simulation color settings and deactivates + launching of an image provider + camera : bool + true: launches an image provider to get images from a camera (unless sim:=true) + debug : bool + true: activates publishing of several debug images + fieldname : str + Loads field settings. Defaults to "small_division_2026" in simulation, "labor" otherwise. + """ + bl = BetterLaunch() + + if fieldname is None: + fieldname = "small_division_2026" if sim else "labor" + + # Load the global parameters + bl.include("bitbots_parameter_blackboard", "parameter_blackboard.launch.py", sim=sim, fieldname=fieldname) + + # Load the diagnostic aggregator + bl.include("bitbots_diagnostic", "aggregator.launch.py") + + # Start the vision + bl.include("bitbots_bringup", "vision.launch.py", sim=sim, camera=camera, debug=debug) diff --git a/src/bitbots_misc/bitbots_bringup/launch/visualization.launch b/src/bitbots_misc/bitbots_bringup/launch/visualization.launch deleted file mode 100644 index 1b6375e9d0..0000000000 --- a/src/bitbots_misc/bitbots_bringup/launch/visualization.launch +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_bringup/launch/visualization.launch.py b/src/bitbots_misc/bitbots_bringup/launch/visualization.launch.py new file mode 100644 index 0000000000..d9e328758b --- /dev/null +++ b/src/bitbots_misc/bitbots_bringup/launch/visualization.launch.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def visualization( + behavior: bool = True, + ipm: bool = False, + motion: bool = True, + game_controller: bool = False, + fieldname: str = "small_division_2026", +): + """ + Parameters + ---------- + behavior : bool + if the behavior should be started + ipm : bool + if the soccer ipm should be used + motion : bool + if the motion should be started + game_controller : bool + if the game controller node should be started + fieldname : str + Loads field settings + """ + bl = BetterLaunch() + + # load the global parameters + bl.include("bitbots_parameter_blackboard", "parameter_blackboard.launch.py") + + # publish dummy imu + bl.node("bitbots_utils", "dummy_imu.py", "dummy_imu") + + # launch motion nodes + if motion: + bl.include("bitbots_bringup", "motion.launch.py", viz=True) + + # launch highlevel nodes, except vision and ipm (we have fake vision instead) + bl.include( + "bitbots_bringup", + "highlevel.launch.py", + behavior=behavior, + localization=False, + game_controller=game_controller, + vision=False, + ipm=False, + ) + + # simulate localization + bl.node( + "tf2_ros", + "static_transform_publisher", + "static_map2odom_tf", + cmd_args=[ + "--x", + "-0.0", + "--y", + "-0.0", + "--z", + "0.0", + "--qx", + "0.0", + "--qy", + "0.0", + "--qz", + "0.0", + "--qw", + "1.0", + "--frame-id", + "map", + "--child-frame-id", + "odom", + ], + log_level=None, + ) + + # translate joint goals to joint states + bl.node("bitbots_utils", "motor_goals_viz_helper.py", "motor_goals_viz_helper") + + # add some visualization tools + bl.include("bitbots_team_communication", "team_comm_test_marker.launch.py", rviz=False) diff --git a/src/bitbots_misc/bitbots_bringup/launch/viz_monitoring.launch b/src/bitbots_misc/bitbots_bringup/launch/viz_monitoring.launch deleted file mode 100644 index dcd19eb014..0000000000 --- a/src/bitbots_misc/bitbots_bringup/launch/viz_monitoring.launch +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/bitbots_misc/bitbots_bringup/launch/viz_monitoring.launch.py b/src/bitbots_misc/bitbots_bringup/launch/viz_monitoring.launch.py new file mode 100644 index 0000000000..808f4d0091 --- /dev/null +++ b/src/bitbots_misc/bitbots_bringup/launch/viz_monitoring.launch.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def viz_monitoring(): + bl = BetterLaunch() + bl.node( + "rviz2", + "rviz2", + "monitoring_rviz", + cmd_args=["-d", bl.find("bitbots_bringup", "monitoring.rviz", "config")], + log_level=None, + ) diff --git a/src/bitbots_misc/bitbots_diagnostic/launch/aggregator.launch b/src/bitbots_misc/bitbots_diagnostic/launch/aggregator.launch deleted file mode 100644 index 3c14fbed5b..0000000000 --- a/src/bitbots_misc/bitbots_diagnostic/launch/aggregator.launch +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_diagnostic/launch/aggregator.launch.py b/src/bitbots_misc/bitbots_diagnostic/launch/aggregator.launch.py new file mode 100644 index 0000000000..a5da86de12 --- /dev/null +++ b/src/bitbots_misc/bitbots_diagnostic/launch/aggregator.launch.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +import logging + +from better_launch import BetterLaunch, launch_this + + +@launch_this +def aggregator(sim: bool = False): + bl = BetterLaunch() + bl.node( + "diagnostic_aggregator", + "aggregator_node", + "analyzers", + param_files=bl.find("bitbots_diagnostic", "analyzers.yaml", "config"), + params={"use_sim_time": sim}, + log_level=logging.WARNING, + ) diff --git a/src/bitbots_misc/bitbots_docs/docs/manual/testing/sim_test.rst b/src/bitbots_misc/bitbots_docs/docs/manual/testing/sim_test.rst index 0d09cdd35d..b9b100aecd 100644 --- a/src/bitbots_misc/bitbots_docs/docs/manual/testing/sim_test.rst +++ b/src/bitbots_misc/bitbots_docs/docs/manual/testing/sim_test.rst @@ -6,8 +6,8 @@ Test Motion .. code-block:: bash - ros2 launch bitbots_mujoco_sim simulation.launch - ros2 launch bitbots_bringup motion_standalone.launch sim:=true + bl bitbots_mujoco_sim simulator.launch.py + bl bitbots_bringup motion_standalone.launch.py --sim true To control walking of the robot, teleop needs to be startet as well: @@ -30,11 +30,11 @@ Test the complete software stack in simulation .. code-block:: bash - ros2 launch bitbots_bringup simulator_teamplayer.launch game_controller:=false + bl bitbots_bringup simulator_teamplayer.launch.py --game_controller false - Start simulator_teamplayer *with* game controller (you can control the current game state): .. code-block:: bash - ros2 launch bitbots_bringup simulator_teamplayer.launch + bl bitbots_bringup simulator_teamplayer.launch.py ros2 run game_controller_hsl sim_gamestate.py diff --git a/src/bitbots_misc/bitbots_docs/docs/manual/testing/test_motion.rst b/src/bitbots_misc/bitbots_docs/docs/manual/testing/test_motion.rst index 4b67922ae0..ae2af9f817 100644 --- a/src/bitbots_misc/bitbots_docs/docs/manual/testing/test_motion.rst +++ b/src/bitbots_misc/bitbots_docs/docs/manual/testing/test_motion.rst @@ -9,13 +9,13 @@ Test Motion in Visualization #. Test Animation: .. code-block:: bash - ros2 launch bitbots_animation_server viz.launch + bl bitbots_animation_server viz.launch.py ros2 run bitbots_animation_server run_animation.py cheering #. Test Walk: .. code-block:: bash - ros2 launch bitbots_bringup motion_standalone.launch + bl bitbots_bringup motion_standalone.launch.py # TODO Start RL motions ros2 run bitbots_teleop teleop_keyboard.py @@ -27,13 +27,13 @@ Test Motion on Robot #. Test Animation on Robot: .. code-block:: bash - ros2 launch bitbots_animation_server test.launch + bl bitbots_animation_server test.launch.py ros2 run bitbots_animation_server run_animation.py cheering #. Test Walk on Robot: .. code-block:: bash - ros2 launch bitbots_bringup motion_standalone.launch + bl bitbots_bringup motion_standalone.launch.py # TODO Start RL motions ros2 run bitbots_teleop teleop_keyboard.py diff --git a/src/bitbots_misc/bitbots_docs/docs/manual/testing/testing.rst b/src/bitbots_misc/bitbots_docs/docs/manual/testing/testing.rst index 2da1cb1d80..9dc606e420 100644 --- a/src/bitbots_misc/bitbots_docs/docs/manual/testing/testing.rst +++ b/src/bitbots_misc/bitbots_docs/docs/manual/testing/testing.rst @@ -69,14 +69,14 @@ It offers us two possibilities. .. code-block:: bash -ros2 launch bitbots_bringup simulator_teamplayer.launch :=false/true +bl bitbots_bringup simulator_teamplayer.launch.py -- false/true Starts the simulator with designated params. .. code-block:: bash -ros2 launch bitbots_bringup highlevel.launch :=true/false +bl bitbots_bringup highlevel.launch.py -- true/false Starts high-level software (Gamecontroller, Teamcomm, Behavior, Vision, Localization, Pathfinding) diff --git a/src/bitbots_misc/bitbots_docs/docs/manual/tutorials/extrinsic_calibration.rst b/src/bitbots_misc/bitbots_docs/docs/manual/tutorials/extrinsic_calibration.rst index 2d664d354a..262a3ae9f4 100644 --- a/src/bitbots_misc/bitbots_docs/docs/manual/tutorials/extrinsic_calibration.rst +++ b/src/bitbots_misc/bitbots_docs/docs/manual/tutorials/extrinsic_calibration.rst @@ -15,7 +15,7 @@ Setup .. code-block:: bash - ros2 launch bitbots_extrinsic_calibration viz_extrinsic_calibration.launch + bl bitbots_extrinsic_calibration viz_extrinsic_calibration.launch.py 2. In Dynamic Reconfigure open the parameters (left panel) for the nodes: :code:`bitbots_extrinsic_imu_calibration` and :code:`bitbots_extrinsic_camera_calibration`. diff --git a/src/bitbots_misc/bitbots_docs/docs/manual/tutorials/launch_files.rst b/src/bitbots_misc/bitbots_docs/docs/manual/tutorials/launch_files.rst index b95da48b2c..91fcfb1112 100644 --- a/src/bitbots_misc/bitbots_docs/docs/manual/tutorials/launch_files.rst +++ b/src/bitbots_misc/bitbots_docs/docs/manual/tutorials/launch_files.rst @@ -4,45 +4,45 @@ Launch Scripts Listed below are the most important launch files. You can display the arguments of the launch files in the terminal by using the command -`ros2 launch .launch -s`. -These launch files have default values, which can be overridden by using the syntax ``:=``. +`bl .launch.py --help`. +These launch files have default values, which can be overridden by using the syntax ``-- ``. Launch Scripts in the ``bitbots_bringup`` Package ------------------------------------------------- -``teamplayer.launch`` -~~~~~~~~~~~~~~~~~~~~~ +``teamplayer.launch.py`` +~~~~~~~~~~~~~~~~~~~~~~~~ This script is used to launch the robot for a game. All game-relevant components including motion are started. To do this, the motor current must be turned on at the robot. After starting, the robot moves to the walk-ready position. -``highlevel.launch`` -~~~~~~~~~~~~~~~~~~~~ +``highlevel.launch.py`` +~~~~~~~~~~~~~~~~~~~~~~~ This launch script starts all game-relevant components except for motion. -``motion_standalone.launch`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``motion_standalone.launch.py`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This launch script starts the motion and all components relevant to motion. When this launch script is started, motors can be controlled and movements can be performed on the robot, such as walking or animations. To do this, the motor current must be turned on. After starting, the robot moves to the walk-ready position. -``vision_standalone.launch`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``vision_standalone.launch.py`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This launch script starts the vision, camera and all relevant components. -``simulator_teamplayer.launch`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``simulator_teamplayer.launch.py`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -This script starts the simulator and the robot software stack in simulation configuration (`use_sim_time:=true`). +This script starts the simulator and the robot software stack in simulation configuration (`--sim true`). -``visualization.launch`` -~~~~~~~~~~~~~~~~~~~~~~~~ +``visualization.launch.py`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~ This script starts RViz and visualizes the robot's sensor data. @@ -50,4 +50,4 @@ This script starts RViz and visualizes the robot's sensor data. ~~~~~~~~~~~~~~~~~~ To receive debugging data via the UDP Bridge, this launch script must be started locally on a laptop. -This can be used together with the `visualization.launch` script to visualize the data. +This can be used together with the `visualization.launch.py` script to visualize the data. diff --git a/src/bitbots_misc/bitbots_education/launch/education.launch b/src/bitbots_misc/bitbots_education/launch/education.launch deleted file mode 100644 index 059c8dd785..0000000000 --- a/src/bitbots_misc/bitbots_education/launch/education.launch +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_education/launch/education.launch.py b/src/bitbots_misc/bitbots_education/launch/education.launch.py new file mode 100644 index 0000000000..98eae1e659 --- /dev/null +++ b/src/bitbots_misc/bitbots_education/launch/education.launch.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def education(): + bl = BetterLaunch() + + bl.include("rosbridge_server", "rosbridge_websocket_launch.xml") + + bl.node( + "web_video_server", + "web_video_server", + "web_video_server_education", + params={"port": 8081}, + ) + + bl.node("bitbots_education", "webserver", "education_webserver") + + bl.process( + ["ros2", "param", "set", "bitbots_vision", "component_debug_image_active", "true", "--timeout", "20000"], + name="education_vision_debug_activator", + output="screen", + ) diff --git a/src/bitbots_misc/bitbots_education/launch/education_simulation.launch b/src/bitbots_misc/bitbots_education/launch/education_simulation.launch deleted file mode 100644 index 875174880e..0000000000 --- a/src/bitbots_misc/bitbots_education/launch/education_simulation.launch +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_education/launch/education_simulation.launch.py b/src/bitbots_misc/bitbots_education/launch/education_simulation.launch.py new file mode 100644 index 0000000000..4c90a0b9de --- /dev/null +++ b/src/bitbots_misc/bitbots_education/launch/education_simulation.launch.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def education_simulation(): + bl = BetterLaunch() + + bl.include("bitbots_education", "education.launch.py") + bl.include("bitbots_mujoco_sim", "simulator.launch.py") + bl.include("bitbots_bringup", "vision.launch.py", sim=True, camera=False) + bl.include("bitbots_bringup", "motion_standalone.launch.py", sim=True) diff --git a/src/bitbots_misc/bitbots_education/setup.py b/src/bitbots_misc/bitbots_education/setup.py index 755789669b..df093082e6 100644 --- a/src/bitbots_misc/bitbots_education/setup.py +++ b/src/bitbots_misc/bitbots_education/setup.py @@ -22,7 +22,7 @@ def generate_data_files(share_path, directory): data_files=[ ("share/ament_index/resource_index/packages", ["resource/" + package_name]), ("share/" + package_name, ["package.xml"]), - ("share/" + package_name + "/launch", glob.glob("launch/*.launch")), + ("share/" + package_name + "/launch", glob.glob("launch/*.launch.py")), ] + generate_data_files("share/" + package_name + "/", "templates/") + generate_data_files("share/" + package_name + "/", "static/"), diff --git a/src/bitbots_misc/bitbots_extrinsic_calibration/config/carrie.yaml b/src/bitbots_misc/bitbots_extrinsic_calibration/config/carrie.yaml index e28f795944..640c397f35 100644 --- a/src/bitbots_misc/bitbots_extrinsic_calibration/config/carrie.yaml +++ b/src/bitbots_misc/bitbots_extrinsic_calibration/config/carrie.yaml @@ -1,11 +1,11 @@ /bitbots_extrinsic_camera_calibration: ros__parameters: - offset_x: 0.24 + offset_x: 0.08 offset_y: 0.0 offset_z: 0.0 /bitbots_extrinsic_imu_calibration: ros__parameters: - offset_x: 0.02 + offset_x: 0.01 offset_y: 0.0 offset_z: 0.0 diff --git a/src/bitbots_misc/bitbots_extrinsic_calibration/config/mickey.yaml b/src/bitbots_misc/bitbots_extrinsic_calibration/config/mickey.yaml index e79b1e87d2..7c8a72d529 100644 --- a/src/bitbots_misc/bitbots_extrinsic_calibration/config/mickey.yaml +++ b/src/bitbots_misc/bitbots_extrinsic_calibration/config/mickey.yaml @@ -1,11 +1,11 @@ /bitbots_extrinsic_camera_calibration: ros__parameters: - offset_x: 0.11 + offset_x: 0.028 offset_y: 0.0 offset_z: 0.0 /bitbots_extrinsic_imu_calibration: ros__parameters: - offset_x: 0.0 - offset_y: 0.0 + offset_x: 0.024 + offset_y: 0.01 offset_z: 0.0 diff --git a/src/bitbots_misc/bitbots_extrinsic_calibration/launch/calibration.launch b/src/bitbots_misc/bitbots_extrinsic_calibration/launch/calibration.launch deleted file mode 100644 index 3cceeb686c..0000000000 --- a/src/bitbots_misc/bitbots_extrinsic_calibration/launch/calibration.launch +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_extrinsic_calibration/launch/calibration.launch.py b/src/bitbots_misc/bitbots_extrinsic_calibration/launch/calibration.launch.py new file mode 100644 index 0000000000..815d20985b --- /dev/null +++ b/src/bitbots_misc/bitbots_extrinsic_calibration/launch/calibration.launch.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +import os + +from better_launch import BetterLaunch, launch_this + + +@launch_this +def calibration(sim: bool = False): + bl = BetterLaunch() + + config_file = os.environ.get("ROBOT_NAME", "default") + ".yaml" + config_path = bl.find("bitbots_extrinsic_calibration", config_file, "config") + + bl.node( + "bitbots_extrinsic_calibration", + "extrinsic_calibration", + "bitbots_extrinsic_camera_calibration", + use_sim_time=sim, + param_files=config_path, + params={ + "parent_frame": "camera_optical_frame_left_uncalibrated", + "child_frame": "zed_left_camera_optical_frame", + }, + ) + bl.node( + "bitbots_extrinsic_calibration", + "extrinsic_calibration", + "bitbots_extrinsic_imu_calibration", + use_sim_time=sim, + param_files=config_path, + params={ + "parent_frame": "imu_frame_uncalibrated", + "child_frame": "imu_frame", + }, + ) diff --git a/src/bitbots_misc/bitbots_extrinsic_calibration/launch/viz_extrinsic_calibration.launch b/src/bitbots_misc/bitbots_extrinsic_calibration/launch/viz_extrinsic_calibration.launch deleted file mode 100644 index 05b646f90d..0000000000 --- a/src/bitbots_misc/bitbots_extrinsic_calibration/launch/viz_extrinsic_calibration.launch +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_extrinsic_calibration/launch/viz_extrinsic_calibration.launch.py b/src/bitbots_misc/bitbots_extrinsic_calibration/launch/viz_extrinsic_calibration.launch.py new file mode 100644 index 0000000000..e327feea7d --- /dev/null +++ b/src/bitbots_misc/bitbots_extrinsic_calibration/launch/viz_extrinsic_calibration.launch.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def viz_extrinsic_calibration(): + """Launch teamplayer with only the necessary components, plus rviz and rqt_reconfigure for extrinsic calibration.""" + bl = BetterLaunch() + + bl.include( + "bitbots_bringup", + "teamplayer.launch.py", + game_controller=False, + behavior=False, + path_planning=False, + world_model=False, + teamcom=False, + monitoring=False, + record=False, + ) + + bl.node( + "rviz2", + "rviz2", + "extrinsic_calibration_rviz", + cmd_args=["-d", bl.find("bitbots_extrinsic_calibration", "extrinsic_calibration.rviz")], + log_level=None, + ) + + bl.node("rqt_reconfigure", "rqt_reconfigure", "rqt_reconfigure") + + bl.process( + ["ros2", "topic", "pub", "--once", "/head_mode", "bitbots_msgs/msg/HeadMode", "{head_mode: 1}"], + name="set_headmode", + output="screen", + ) diff --git a/src/bitbots_misc/bitbots_ipm/launch/ipm.launch b/src/bitbots_misc/bitbots_ipm/launch/ipm.launch deleted file mode 100644 index 4168bca2ec..0000000000 --- a/src/bitbots_misc/bitbots_ipm/launch/ipm.launch +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_ipm/launch/ipm.launch.py b/src/bitbots_misc/bitbots_ipm/launch/ipm.launch.py new file mode 100644 index 0000000000..c3e8c4229b --- /dev/null +++ b/src/bitbots_misc/bitbots_ipm/launch/ipm.launch.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def ipm( + sim: bool = False, + full_image: bool = False, + markers: bool = True, + rviz: bool = False, +): + """ + Parameters + ---------- + sim : bool + Whether the robot is running in simulation. + full_image : bool + Whether to project the full-size RGB image for debugging or showcasing. + markers : bool + Whether to publish markers for visualization of the detected objects in RViz. + rviz : bool + Whether to start RViz with the ipm configuration. + """ + bl = BetterLaunch() + + bl.node( + "ipm_image_node", + "ipm", + "ipm_line_mask", + remaps={ + "camera_info": "/zed/zed_node/rgb/camera_info", + "input": "/line_mask_in_image", + "projected_point_cloud": "/line_mask_relative_pc", + }, + params={ + "output_frame": "base_footprint", + "scale": 0.2, + "type": "mask", + "use_distortion": True, + }, + use_sim_time=sim, + ) + + bl.node( + "soccer_ipm", + "ipm", + "soccer_ipm", + remaps={"camera_info": "/zed/zed_node/rgb/camera_info"}, + param_files=bl.find("bitbots_ipm", "soccer_ipm.yaml", "config"), + use_sim_time=sim, + ) + + if full_image: + bl.node( + "ipm_image_node", + "ipm", + "ipm_image", + remaps={ + "camera_info": "/zed/zed_node/rgb/camera_info", + "input": "/zed/zed_node/rgb/image_rect_color", + "projected_point_cloud": "/projected_camera_image", + }, + params={ + "output_frame": "base_footprint", + "scale": 1.0, + "type": "rgb_image", + "use_distortion": True, + }, + use_sim_time=sim, + ) + + if markers: + bl.node( + "soccer_vision_3d_rviz_markers", + "visualizer", + "soccer_vision_3d_rviz_marker_visualizer", + remaps={ + "soccer_vision_3d/field_boundary": "/field_boundary_relative", + "soccer_vision_3d/balls": "/balls_relative", + "soccer_vision_3d/goalposts": "/goal_posts_relative", + "soccer_vision_3d/robots": "/robots_relative", + "soccer_vision_3d/obstacles": "/obstacles_relative", + "soccer_vision_3d/markings": "/markings_relative", + }, + # IMPORTANT: Ball diameter is ALSO defined in the soccer_ipm config file + params={"ball_diameter": 0.153}, + use_sim_time=sim, + ) + + if rviz: + bl.node( + "rviz2", + "rviz2", + cmd_args=["-d", bl.find("bitbots_ipm", "ipm.rviz", "config")], + log_level=None, + ) diff --git a/src/bitbots_misc/bitbots_parameter_blackboard/launch/parameter_blackboard.launch.py b/src/bitbots_misc/bitbots_parameter_blackboard/launch/parameter_blackboard.launch.py index 2cacbbe705..f82811cf78 100644 --- a/src/bitbots_misc/bitbots_parameter_blackboard/launch/parameter_blackboard.launch.py +++ b/src/bitbots_misc/bitbots_parameter_blackboard/launch/parameter_blackboard.launch.py @@ -1,65 +1,53 @@ -from __future__ import annotations - +#!/usr/bin/env python3 +import logging import os -from ament_index_python.packages import get_package_share_directory -from launch import LaunchDescription -from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription, OpaqueFunction -from launch.launch_description_sources import AnyLaunchDescriptionSource -from launch.substitutions import LaunchConfiguration, PathJoinSubstitution, PythonExpression -from launch_ros.actions import Node -from launch_ros.parameter_descriptions import ParameterFile +from better_launch import BetterLaunch, launch_this -def generate_launch_description() -> LaunchDescription: - package_name = "bitbots_parameter_blackboard" - package_share = get_package_share_directory(package_name) +@launch_this +def parameter_blackboard(sim: bool = False, fieldname: str = None): + """Loads the global parameters onto a parameter_blackboard node. - def create_node(context, *args, **kwargs): - in_sim = LaunchConfiguration("sim").perform(context) == "true" - field_name = LaunchConfiguration("fieldname").perform(context) + Parameters + ---------- + sim : bool + Whether we are running in simulation. + fieldname : str + Field name to load parameters for. Defaults to "hsl_kid" in simulation, "labor" otherwise. + """ + bl = BetterLaunch() - parameters = [ - {"simulation_active": in_sim}, - {"use_sim_time": in_sim}, - {"field.name": field_name}, - ParameterFile(PathJoinSubstitution([package_share, "config", "fields", field_name, "config.yaml"])), - ParameterFile(PathJoinSubstitution([package_share, "config", "global_parameters.yaml"])), - ParameterFile(PathJoinSubstitution([package_share, "config", "game_settings.yaml"])), - ] + if fieldname is None: + fieldname = "hsl_kid" if sim else "labor" - robot_domain = os.environ.get("ROS_DOMAIN_ID") - if in_sim and robot_domain is not None: - parameters.append( - ParameterFile( - PathJoinSubstitution([package_share, "config", f"sim_game_settings_{int(robot_domain)}.yaml"]) - ) - ) + bl.include("bitbots_utils", "welcome.launch.py") - return [ - Node( - package="demo_nodes_cpp", - executable="parameter_blackboard", - name="parameter_blackboard", - arguments=["--ros-args", "--log-level", "WARN"], - parameters=parameters, - ) - ] + param_files = [ + bl.find("bitbots_parameter_blackboard", "config.yaml", f"config/fields/{fieldname}"), + bl.find("bitbots_parameter_blackboard", "global_parameters.yaml", "config"), + bl.find("bitbots_parameter_blackboard", "game_settings.yaml", "config"), + ] - sim = LaunchConfiguration("sim") - return LaunchDescription( - [ - DeclareLaunchArgument("sim", default_value="false"), - DeclareLaunchArgument( - "fieldname", - default_value=PythonExpression(["'hsl_kid' if '", sim, "' == 'true' else 'labor'"]), - description="Field name to load parameters for.", - ), - IncludeLaunchDescription( - AnyLaunchDescriptionSource( - PathJoinSubstitution([get_package_share_directory("bitbots_utils"), "launch", "welcome.launch"]) - ) - ), - OpaqueFunction(function=create_node), - ] + robot_domain = os.environ.get("ROS_DOMAIN_ID") + if sim and robot_domain is not None: + param_files.append( + bl.find( + "bitbots_parameter_blackboard", + f"sim_game_settings_{int(robot_domain)}.yaml", + "config", + ) + ) + + bl.node( + "demo_nodes_cpp", + "parameter_blackboard", + "parameter_blackboard", + params={ + "simulation_active": sim, + "use_sim_time": sim, + "field.name": fieldname, + }, + param_files=param_files, + log_level=logging.WARNING, ) diff --git a/src/bitbots_misc/bitbots_player_state/launch/player_state_aggregator.launch b/src/bitbots_misc/bitbots_player_state/launch/player_state_aggregator.launch deleted file mode 100644 index c2e8624595..0000000000 --- a/src/bitbots_misc/bitbots_player_state/launch/player_state_aggregator.launch +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_player_state/launch/player_state_aggregator.launch.py b/src/bitbots_misc/bitbots_player_state/launch/player_state_aggregator.launch.py new file mode 100644 index 0000000000..d267ae18c5 --- /dev/null +++ b/src/bitbots_misc/bitbots_player_state/launch/player_state_aggregator.launch.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def player_state_aggregator(sim: bool = False, config: str = None): + """ + Parameters + ---------- + sim : bool + Whether to use simulation time + config : str + Player state aggregator configuration file + """ + bl = BetterLaunch() + + if config is None: + config = bl.find("bitbots_player_state", "player_state_aggregator.yaml", "config") + + bl.node( + "bitbots_player_state", + "player_state_aggregator", + "player_state_aggregator", + param_files=config, + use_sim_time=sim, + ) diff --git a/src/bitbots_misc/bitbots_player_state/setup.py b/src/bitbots_misc/bitbots_player_state/setup.py index 6ebcd47d58..146cc073e7 100644 --- a/src/bitbots_misc/bitbots_player_state/setup.py +++ b/src/bitbots_misc/bitbots_player_state/setup.py @@ -12,7 +12,7 @@ ("share/ament_index/resource_index/packages", ["resource/" + package_name]), ("share/" + package_name, ["package.xml"]), ("share/" + package_name + "/config", glob("config/*.yaml")), - ("share/" + package_name + "/launch", glob("launch/*.launch")), + ("share/" + package_name + "/launch", glob("launch/*.launch.py")), ], install_requires=["setuptools"], zip_safe=True, diff --git a/src/bitbots_misc/bitbots_robot_description/launch/load_robot_description.launch b/src/bitbots_misc/bitbots_robot_description/launch/load_robot_description.launch deleted file mode 100644 index 2e62528c2a..0000000000 --- a/src/bitbots_misc/bitbots_robot_description/launch/load_robot_description.launch +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_robot_description/launch/load_robot_description.launch.py b/src/bitbots_misc/bitbots_robot_description/launch/load_robot_description.launch.py new file mode 100644 index 0000000000..e624eb3349 --- /dev/null +++ b/src/bitbots_misc/bitbots_robot_description/launch/load_robot_description.launch.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this +from better_launch.convenience import read_robot_description, robot_state_publisher + + +@launch_this +def load_robot_description(sim: bool = False): + bl = BetterLaunch() + + description = read_robot_description("piplus_description", "pi_plus_22dof.urdf.xacro", "urdf") + robot_state_publisher( + description, + node_name="robot_state_publisher", + anonymous=False, + params={"publish_frequency": 100.0, "use_sim_time": sim}, + ) + + bl.include("bitbots_extrinsic_calibration", "calibration.launch.py", sim=sim) diff --git a/src/bitbots_misc/bitbots_teleop/launch/robot_teleop.launch b/src/bitbots_misc/bitbots_teleop/launch/robot_teleop.launch deleted file mode 100644 index 081f8fc5e0..0000000000 --- a/src/bitbots_misc/bitbots_teleop/launch/robot_teleop.launch +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/bitbots_misc/bitbots_teleop/launch/robot_teleop.launch.py b/src/bitbots_misc/bitbots_teleop/launch/robot_teleop.launch.py new file mode 100644 index 0000000000..6cb5820772 --- /dev/null +++ b/src/bitbots_misc/bitbots_teleop/launch/robot_teleop.launch.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def robot_teleop(type: str = "noname", head: bool = False): + """ + Parameters + ---------- + type : str + Sets the controller type e.g. noname, xbox + """ + bl = BetterLaunch() + + bl.node( + "joy_linux", + "joy_linux_node", + "joy_node", + params={ + "deadzone": 0.1, + "autorepeat_rate": 10.0, + }, + ) + + bl.node( + "bitbots_teleop", + "joy_node", + "joy_to_twist", + params={ + "type": type, + "head": head, + }, + param_files=bl.find("bitbots_teleop", "controller.yaml", "config"), + ) diff --git a/src/bitbots_misc/bitbots_teleop/setup.py b/src/bitbots_misc/bitbots_teleop/setup.py index b480a1822f..a7cdafb57d 100644 --- a/src/bitbots_misc/bitbots_teleop/setup.py +++ b/src/bitbots_misc/bitbots_teleop/setup.py @@ -12,7 +12,7 @@ ("share/ament_index/resource_index/packages", ["resource/" + package_name]), ("share/" + package_name, ["package.xml"]), ("share/" + package_name + "/config", glob.glob("config/*.yaml")), - ("share/" + package_name + "/launch", glob.glob("launch/*.launch")), + ("share/" + package_name + "/launch", glob.glob("launch/*.launch.py")), ], scripts=["scripts/teleop_keyboard.py", "scripts/kick_teleop.py"], install_requires=[ diff --git a/src/bitbots_misc/bitbots_tts/launch/tts.launch b/src/bitbots_misc/bitbots_tts/launch/tts.launch deleted file mode 100644 index 50abdd507f..0000000000 --- a/src/bitbots_misc/bitbots_tts/launch/tts.launch +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/bitbots_misc/bitbots_tts/launch/tts.launch.py b/src/bitbots_misc/bitbots_tts/launch/tts.launch.py new file mode 100644 index 0000000000..c0845a864d --- /dev/null +++ b/src/bitbots_misc/bitbots_tts/launch/tts.launch.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def tts(): + bl = BetterLaunch() + bl.node( + "bitbots_tts", + "tts", + "bitbots_tts", + params="tts_config.yaml", + ) diff --git a/src/bitbots_misc/bitbots_tts/setup.py b/src/bitbots_misc/bitbots_tts/setup.py index 9d702643b1..27f30357e9 100644 --- a/src/bitbots_misc/bitbots_tts/setup.py +++ b/src/bitbots_misc/bitbots_tts/setup.py @@ -11,7 +11,7 @@ ("share/" + package_name, ["package.xml"]), ("share/ament_index/resource_index/packages", ["resource/" + package_name]), ("share/" + package_name + "/config", glob.glob("config/*.yaml")), - ("share/" + package_name + "/launch", glob.glob("launch/*.launch")), + ("share/" + package_name + "/launch", glob.glob("launch/*.launch.py")), ], install_requires=[ "setuptools", diff --git a/src/bitbots_misc/bitbots_utils/launch/welcome.launch b/src/bitbots_misc/bitbots_utils/launch/welcome.launch deleted file mode 100644 index f9f014280a..0000000000 --- a/src/bitbots_misc/bitbots_utils/launch/welcome.launch +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/bitbots_misc/bitbots_utils/launch/welcome.launch.py b/src/bitbots_misc/bitbots_utils/launch/welcome.launch.py new file mode 100644 index 0000000000..9e148d8b51 --- /dev/null +++ b/src/bitbots_misc/bitbots_utils/launch/welcome.launch.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def welcome(): + """Print Bit-Bot on the terminal.""" + bl = BetterLaunch() + art_file = bl.find("bitbots_utils", "welcome_art.txt", "config") + bl.exec(["cat", art_file]) diff --git a/src/bitbots_misc/bitbots_whistle_detector/launch/whistle_detector.launch b/src/bitbots_misc/bitbots_whistle_detector/launch/whistle_detector.launch deleted file mode 100644 index f3aa6918e5..0000000000 --- a/src/bitbots_misc/bitbots_whistle_detector/launch/whistle_detector.launch +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/bitbots_misc/bitbots_whistle_detector/launch/whistle_detector.launch.py b/src/bitbots_misc/bitbots_whistle_detector/launch/whistle_detector.launch.py new file mode 100644 index 0000000000..57cbca4c3d --- /dev/null +++ b/src/bitbots_misc/bitbots_whistle_detector/launch/whistle_detector.launch.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def whistle_detector(sim: bool = False): + bl = BetterLaunch() + bl.node("bitbots_whistle_detector", "whistle_detector", "bitbots_whistle_detector", params={"use_sim_time": sim}) diff --git a/src/bitbots_misc/bitbots_whistle_detector/setup.py b/src/bitbots_misc/bitbots_whistle_detector/setup.py index d9f720d9dc..4206d35c59 100644 --- a/src/bitbots_misc/bitbots_whistle_detector/setup.py +++ b/src/bitbots_misc/bitbots_whistle_detector/setup.py @@ -18,7 +18,7 @@ ("share/" + package_name, ["package.xml"]), ("share/ament_index/resource_index/packages", ["resource/" + package_name]), ("share/" + package_name + "/config", glob.glob("config/*.yaml")), - ("share/" + package_name + "/launch", glob.glob("launch/*.launch")), + ("share/" + package_name + "/launch", glob.glob("launch/*.launch.py")), ], install_requires=[ "launch", diff --git a/src/bitbots_misc/system_monitor/launch/system_monitor.launch b/src/bitbots_misc/system_monitor/launch/system_monitor.launch deleted file mode 100644 index 2831780262..0000000000 --- a/src/bitbots_misc/system_monitor/launch/system_monitor.launch +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/bitbots_misc/system_monitor/launch/system_monitor.launch.py b/src/bitbots_misc/system_monitor/launch/system_monitor.launch.py new file mode 100644 index 0000000000..5d1e54ebbc --- /dev/null +++ b/src/bitbots_misc/system_monitor/launch/system_monitor.launch.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def system_monitor(): + bl = BetterLaunch() + bl.node( + "system_monitor", + "monitor", + "system_monitor", + param_files=bl.find("system_monitor", "config.yaml", "config"), + ) diff --git a/src/bitbots_misc/system_monitor/launch/viz.launch b/src/bitbots_misc/system_monitor/launch/viz.launch deleted file mode 100644 index af193e66c5..0000000000 --- a/src/bitbots_misc/system_monitor/launch/viz.launch +++ /dev/null @@ -1,4 +0,0 @@ - - - diff --git a/src/bitbots_misc/system_monitor/launch/viz.launch.py b/src/bitbots_misc/system_monitor/launch/viz.launch.py new file mode 100644 index 0000000000..e7130d58ab --- /dev/null +++ b/src/bitbots_misc/system_monitor/launch/viz.launch.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def viz(): + bl = BetterLaunch() + bl.node( + "plotjuggler", + "plotjuggler", + "plotjuggler", + anonymous=True, + cmd_args=["--layout", bl.find("system_monitor", "plotjuggler_layout.xml", "config")], + ) diff --git a/src/bitbots_misc/system_monitor/setup.py b/src/bitbots_misc/system_monitor/setup.py index 0de29d43a6..21f79d74b6 100644 --- a/src/bitbots_misc/system_monitor/setup.py +++ b/src/bitbots_misc/system_monitor/setup.py @@ -13,7 +13,7 @@ ("share/" + package_name + "/config", glob.glob("config/*.yaml")), ("share/" + package_name + "/config", glob.glob("config/*.rviz")), ("share/" + package_name + "/config", glob.glob("config/*.xml")), - ("share/" + package_name + "/launch", glob.glob("launch/*.launch")), + ("share/" + package_name + "/launch", glob.glob("launch/*.launch.py")), ], install_requires=[ "setuptools", diff --git a/src/bitbots_motion/bitbots_animation_server/launch/animation.launch b/src/bitbots_motion/bitbots_animation_server/launch/animation.launch deleted file mode 100644 index 242309fbcf..0000000000 --- a/src/bitbots_motion/bitbots_animation_server/launch/animation.launch +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/bitbots_motion/bitbots_animation_server/launch/animation.launch.py b/src/bitbots_motion/bitbots_animation_server/launch/animation.launch.py new file mode 100644 index 0000000000..8a28e5889f --- /dev/null +++ b/src/bitbots_motion/bitbots_animation_server/launch/animation.launch.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def animation(sim: bool = False): + """ + Parameters + ---------- + sim : bool + Disables some checks for hardware, since we are in simulation. + """ + bl = BetterLaunch() + bl.node( + "bitbots_animation_server", + "animation_node", + "animation_server", + use_sim_time=sim, + ) diff --git a/src/bitbots_motion/bitbots_animation_server/launch/test.launch b/src/bitbots_motion/bitbots_animation_server/launch/test.launch deleted file mode 100644 index ac71266362..0000000000 --- a/src/bitbots_motion/bitbots_animation_server/launch/test.launch +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/src/bitbots_motion/bitbots_animation_server/launch/test.launch.py b/src/bitbots_motion/bitbots_animation_server/launch/test.launch.py new file mode 100644 index 0000000000..ea9e25fc5b --- /dev/null +++ b/src/bitbots_motion/bitbots_animation_server/launch/test.launch.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def test(sim: bool = False): + bl = BetterLaunch() + + if not sim: + bl.include("livelybot_bringup", "lowlevel.launch") + + bl.include("bitbots_parameter_blackboard", "parameter_blackboard.launch.py") + bl.include("bitbots_robot_description", "load_robot_description.launch.py") + + bl.node("bitbots_animation_server", "animation_node", "animation_server") + bl.node("bitbots_animation_server", "animation_hcm_bridge.py", "animation_hcm_bridge") diff --git a/src/bitbots_motion/bitbots_animation_server/setup.py b/src/bitbots_motion/bitbots_animation_server/setup.py index c41689c5fd..bd4aed0219 100644 --- a/src/bitbots_motion/bitbots_animation_server/setup.py +++ b/src/bitbots_motion/bitbots_animation_server/setup.py @@ -11,7 +11,7 @@ data_files=[ ("share/ament_index/resource_index/packages", ["resource/" + package_name]), ("share/" + package_name, ["package.xml"]), - ("share/" + package_name + "/launch", glob.glob("launch/*.launch")), + ("share/" + package_name + "/launch", glob.glob("launch/*.launch.py")), ], scripts=["scripts/animation_hcm_bridge.py", "scripts/run_animation.py"], install_requires=[ diff --git a/src/bitbots_motion/bitbots_emergency/launch/emergency_listener.launch b/src/bitbots_motion/bitbots_emergency/launch/emergency_listener.launch deleted file mode 100644 index 9d99932341..0000000000 --- a/src/bitbots_motion/bitbots_emergency/launch/emergency_listener.launch +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/bitbots_motion/bitbots_emergency/launch/emergency_listener.launch.py b/src/bitbots_motion/bitbots_emergency/launch/emergency_listener.launch.py new file mode 100644 index 0000000000..364e6c2541 --- /dev/null +++ b/src/bitbots_motion/bitbots_emergency/launch/emergency_listener.launch.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def emergency_listener(emergency_button: bool = True): + bl = BetterLaunch() + + if emergency_button: + bl.node("bitbots_emergency", "EMERGENCY_NODE_LISTENER", "emergency_node_listener") diff --git a/src/bitbots_motion/bitbots_emergency/launch/emergency_publisher.launch b/src/bitbots_motion/bitbots_emergency/launch/emergency_publisher.launch deleted file mode 100644 index a5956b0500..0000000000 --- a/src/bitbots_motion/bitbots_emergency/launch/emergency_publisher.launch +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/bitbots_motion/bitbots_emergency/launch/emergency_publisher.launch.py b/src/bitbots_motion/bitbots_emergency/launch/emergency_publisher.launch.py new file mode 100644 index 0000000000..5b82756292 --- /dev/null +++ b/src/bitbots_motion/bitbots_emergency/launch/emergency_publisher.launch.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def emergency_publisher(emergency_button: bool = True, robot_ip: str = "10.10.10.10"): + bl = BetterLaunch() + + if emergency_button: + script = bl.find("bitbots_emergency", "publisher.sh", "scripts") + bl.process(["bash", script, robot_ip], name="publisher", output="screen") diff --git a/src/bitbots_motion/bitbots_hcm/docs/index.rst b/src/bitbots_motion/bitbots_hcm/docs/index.rst index ccea79bcfb..0c4cd0e312 100644 --- a/src/bitbots_motion/bitbots_hcm/docs/index.rst +++ b/src/bitbots_motion/bitbots_hcm/docs/index.rst @@ -62,11 +62,11 @@ Sensor data is not influenced by the HCM, since it does not need to be mutexed. How the HCM is started ---------------------- -The easiest way to start the HCM is to launch the complete motion (`ros2 launch bitbots_bringup motion_standalone.launch`). +The easiest way to start the HCM is to launch the complete motion (`bl bitbots_bringup motion_standalone.launch.py`). For debugging it is sometimes better to launch the single parts by themselves. -The HCM needs the animation server (`ros2 launch bitbots_animation_server animation.launch`) to work because it is needed to perform falling and stand up animations. +The HCM needs the animation server (`bl bitbots_animation_server animation.launch.py`) to work because it is needed to perform falling and stand up animations. To be able to actually control the hardware, ros_control needs to run (`ros2 launch bitbots_ros_control ros_control_standalone.launch`). -Finally launch the HCM itself (`ros2 launch bitbots_hcm hcm_standalone.launch`). +Finally launch the HCM itself (`bl bitbots_hcm hcm_standalone.launch.py`). What to do when it does not work diff --git a/src/bitbots_motion/bitbots_hcm/launch/hcm.launch b/src/bitbots_motion/bitbots_hcm/launch/hcm.launch deleted file mode 100644 index 59db665bb1..0000000000 --- a/src/bitbots_motion/bitbots_hcm/launch/hcm.launch +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/src/bitbots_motion/bitbots_hcm/launch/hcm.launch.py b/src/bitbots_motion/bitbots_hcm/launch/hcm.launch.py new file mode 100644 index 0000000000..bea6fb8b26 --- /dev/null +++ b/src/bitbots_motion/bitbots_hcm/launch/hcm.launch.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def hcm(sim: bool = False, viz: bool = False, wolfgang: bool = True): + """ + Parameters + ---------- + sim : bool + Disables some checks for hardware, since we are in simulation. + viz : bool + Disables all checks for hardware, since we are in visualization. + """ + bl = BetterLaunch() + + if wolfgang: + bl.node( + "bitbots_hcm", + "HCM", + "hcm_cpp", + params={ + "simulation_active": sim, + "visualization_active": viz, + }, + use_sim_time=sim, + ) diff --git a/src/bitbots_motion/bitbots_hcm/launch/test.launch b/src/bitbots_motion/bitbots_hcm/launch/test.launch deleted file mode 100644 index 0af40d8c83..0000000000 --- a/src/bitbots_motion/bitbots_hcm/launch/test.launch +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/bitbots_motion/bitbots_hcm/launch/test.launch.py b/src/bitbots_motion/bitbots_hcm/launch/test.launch.py new file mode 100644 index 0000000000..b38d813cf0 --- /dev/null +++ b/src/bitbots_motion/bitbots_hcm/launch/test.launch.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def test(sim: bool = False): + """ + Parameters + ---------- + sim : bool + Disables checks for hardware, since we are in simulation. + """ + bl = BetterLaunch() + + bl.include("bitbots_parameter_blackboard", "parameter_blackboard.launch.py", sim=sim) + bl.include("bitbots_robot_description", "load_robot_description.launch.py", sim=sim) + + if not sim: + bl.include("livelybot_bringup", "lowlevel.launch") + + bl.include("bitbots_animation_server", "animation.launch.py", sim=sim) + bl.include("bitbots_hcm", "hcm.launch.py", sim=sim) diff --git a/src/bitbots_motion/bitbots_head_mover/launch/head_mover.launch b/src/bitbots_motion/bitbots_head_mover/launch/head_mover.launch deleted file mode 100644 index b1742b2a5a..0000000000 --- a/src/bitbots_motion/bitbots_head_mover/launch/head_mover.launch +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/bitbots_motion/bitbots_head_mover/launch/head_mover.launch.py b/src/bitbots_motion/bitbots_head_mover/launch/head_mover.launch.py new file mode 100644 index 0000000000..e23a28eab0 --- /dev/null +++ b/src/bitbots_motion/bitbots_head_mover/launch/head_mover.launch.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def head_mover(tf_prefix: str = "", sim: bool = False): + bl = BetterLaunch() + bl.node( + "bitbots_head_mover", + "move_head", + "head_mover", + use_sim_time=sim, + max_respawns=-1, + ) diff --git a/src/bitbots_motion/bitbots_head_mover/launch/head_mover_standalone.launch b/src/bitbots_motion/bitbots_head_mover/launch/head_mover_standalone.launch deleted file mode 100644 index 627da0c691..0000000000 --- a/src/bitbots_motion/bitbots_head_mover/launch/head_mover_standalone.launch +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/src/bitbots_motion/bitbots_head_mover/launch/head_mover_standalone.launch.py b/src/bitbots_motion/bitbots_head_mover/launch/head_mover_standalone.launch.py new file mode 100644 index 0000000000..3e53c41b64 --- /dev/null +++ b/src/bitbots_motion/bitbots_head_mover/launch/head_mover_standalone.launch.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def head_mover_standalone(sim: bool = False): + bl = BetterLaunch() + + bl.include("bitbots_parameter_blackboard", "parameter_blackboard.launch.py", sim=sim) + bl.include("bitbots_robot_description", "load_robot_description.launch.py", sim=sim) + bl.include("bitbots_head_mover", "head_mover.launch.py", sim=sim) diff --git a/src/bitbots_motion/bitbots_head_mover/launch/test.launch b/src/bitbots_motion/bitbots_head_mover/launch/test.launch deleted file mode 100644 index 4daba5d2fe..0000000000 --- a/src/bitbots_motion/bitbots_head_mover/launch/test.launch +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/bitbots_motion/bitbots_head_mover/launch/test.launch.py b/src/bitbots_motion/bitbots_head_mover/launch/test.launch.py new file mode 100644 index 0000000000..cacf1ee2a4 --- /dev/null +++ b/src/bitbots_motion/bitbots_head_mover/launch/test.launch.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def test(sim: bool = False, viz: bool = False, tf_prefix: str = ""): + bl = BetterLaunch() + + if not sim and not viz: + bl.include("livelybot_bringup", "lowlevel.launch") + + bl.include("bitbots_parameter_blackboard", "parameter_blackboard.launch.py", sim=sim) + bl.include("bitbots_robot_description", "load_robot_description.launch.py", sim=sim) + + # launch the base footprint + bl.node( + "humanoid_base_footprint", + "base_footprint", + "base_footprint", + params={"support_state_topics": ["walk_support_state"]}, + use_sim_time=sim, + ) + + # launch the odometry + bl.include("bitbots_odometry", "odometry.launch.py", sim=sim) + + bl.include("bitbots_ball_filter", "ball_filter.launch.py", sim=sim) + + if viz: + # translate joint goals to joint states + bl.node("bitbots_utils", "motor_goals_viz_helper.py", "MotorGoalsVizHelper", cmd_args=["--head"]) + # fake IMU needed for odometry + bl.node("bitbots_utils", "dummy_imu.py", "DummyImu") + # create fake tf from map to robot + bl.node( + "tf2_ros", + "static_transform_publisher", + "static_map2odom_tf", + cmd_args=[ + "--x", + "-0.0", + "--y", + "-0.0", + "--z", + "0.0", + "--qx", + "0.0", + "--qy", + "0.0", + "--qz", + "0.0", + "--qw", + "1.0", + "--frame-id", + "map", + "--child-frame-id", + "odom", + ], + log_level=None, + ) + else: + # launch vision + bl.include("bitbots_bringup", "vision.launch.py", sim=sim) + + # launch inverse perspective mapping (ipm) + bl.include("bitbots_ipm", "ipm.launch.py", sim=sim) + + bl.include("bitbots_head_mover", "head_mover.launch.py", sim=sim) diff --git a/src/bitbots_motion/bitbots_rl_motion/launch/rl_motion.launch b/src/bitbots_motion/bitbots_rl_motion/launch/rl_motion.launch deleted file mode 100644 index dc1852dd13..0000000000 --- a/src/bitbots_motion/bitbots_rl_motion/launch/rl_motion.launch +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/bitbots_motion/bitbots_rl_motion/launch/rl_motion.launch.py b/src/bitbots_motion/bitbots_rl_motion/launch/rl_motion.launch.py new file mode 100644 index 0000000000..8b2c139917 --- /dev/null +++ b/src/bitbots_motion/bitbots_rl_motion/launch/rl_motion.launch.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def rl_motion( + sim: bool = False, + walk: bool = True, + kick: bool = True, + mjlab_walk: bool = False, + mjlab_getup: bool = True, +): + bl = BetterLaunch() + + if walk: + bl.node( + "bitbots_rl_motion", + "walk_node", + "walk_node", + param_files=bl.find("bitbots_rl_motion", "playground_walk_model.yaml", "configs"), + use_sim_time=sim, + ) + + if kick: + bl.node( + "bitbots_rl_motion", + "kick_ball_node", + "kick_ball_node", + param_files=bl.find("bitbots_rl_motion", "kick_ball_model.yaml", "configs"), + use_sim_time=sim, + ) + + if mjlab_walk: + bl.node( + "bitbots_rl_motion", + "mjlab_walk_node", + "mjlab_walk_node", + param_files=bl.find("bitbots_rl_motion", "mjlab_walk_model.yaml", "configs"), + use_sim_time=sim, + ) + + if mjlab_getup: + bl.node( + "bitbots_rl_motion", + "mjlab_getup_node", + "mjlab_getup_node", + param_files=bl.find("bitbots_rl_motion", "mjlab_getup_model.yaml", "configs"), + use_sim_time=sim, + ) diff --git a/src/bitbots_motion/bitbots_rl_motion/setup.py b/src/bitbots_motion/bitbots_rl_motion/setup.py index 74320b55c1..0ad4bac019 100644 --- a/src/bitbots_motion/bitbots_rl_motion/setup.py +++ b/src/bitbots_motion/bitbots_rl_motion/setup.py @@ -16,7 +16,7 @@ glob.glob("models/*.onnx"), ), ("share/" + package_name + "/configs", glob.glob("configs/*.yaml")), - ("share/" + package_name + "/launch", glob.glob("launch/*.launch")), + ("share/" + package_name + "/launch", glob.glob("launch/*.launch.py")), ], install_requires=["setuptools"], zip_safe=True, diff --git a/src/bitbots_navigation/bitbots_localization/launch/localization.launch b/src/bitbots_navigation/bitbots_localization/launch/localization.launch deleted file mode 100644 index 16090d1aed..0000000000 --- a/src/bitbots_navigation/bitbots_localization/launch/localization.launch +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/bitbots_navigation/bitbots_localization/launch/localization.launch.py b/src/bitbots_navigation/bitbots_localization/launch/localization.launch.py new file mode 100644 index 0000000000..171d0ace65 --- /dev/null +++ b/src/bitbots_navigation/bitbots_localization/launch/localization.launch.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def localization(tf_prefix: str = "", sim: bool = False): + """ + Parameters + ---------- + sim : bool + true: activates simulation time and might load different parameters + """ + bl = BetterLaunch() + + bl.node( + "bitbots_localization", + "localization", + "bitbots_localization", + param_files=bl.find("bitbots_localization", "config.yaml", "config"), + params={ + "ros.odom_frame": f"{tf_prefix}odom", + "ros.base_footprint_frame": f"{tf_prefix}base_footprint", + "ros.map_frame": f"{tf_prefix}map", + "ros.publishing_frame": f"{tf_prefix}localization_raw", + }, + use_sim_time=sim, + ) + + bl.node( + "bitbots_localization_handler", + "localization_handler", + "bitbots_localization_handler", + params={ + "odom_frame": f"{tf_prefix}odom", + "base_footprint_frame": f"{tf_prefix}base_footprint", + "walking_moved_distance": 0.5, + }, + use_sim_time=sim, + ) diff --git a/src/bitbots_navigation/bitbots_localization/launch/test.launch b/src/bitbots_navigation/bitbots_localization/launch/test.launch deleted file mode 100644 index bd5f5f245f..0000000000 --- a/src/bitbots_navigation/bitbots_localization/launch/test.launch +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/src/bitbots_navigation/bitbots_localization/launch/test.launch.py b/src/bitbots_navigation/bitbots_localization/launch/test.launch.py new file mode 100644 index 0000000000..ce24586ae4 --- /dev/null +++ b/src/bitbots_navigation/bitbots_localization/launch/test.launch.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def test(): + bl = BetterLaunch() + + bl.include( + "bitbots_bringup", + "simulator_teamplayer.launch.py", + behavior=False, + game_controller=False, + localization=True, + ) + + bl.node( + "rviz2", + "rviz2", + "rviz", + anonymous=True, + cmd_args=["-d", bl.find("bitbots_localization", "localization.rviz", "config")], + log_level=None, + ) diff --git a/src/bitbots_navigation/bitbots_odometry/launch/odometry.launch b/src/bitbots_navigation/bitbots_odometry/launch/odometry.launch deleted file mode 100644 index a01c3a2cc0..0000000000 --- a/src/bitbots_navigation/bitbots_odometry/launch/odometry.launch +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/src/bitbots_navigation/bitbots_odometry/launch/odometry.launch.py b/src/bitbots_navigation/bitbots_odometry/launch/odometry.launch.py new file mode 100644 index 0000000000..505ca5e672 --- /dev/null +++ b/src/bitbots_navigation/bitbots_odometry/launch/odometry.launch.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +import os + +from better_launch import BetterLaunch, launch_this + + +@launch_this +def odometry(sim: bool = False): + bl = BetterLaunch() + + tf_prefix = os.environ.get("ROS_NAMESPACE", "") + config_file = f"odometry_config_{os.environ.get('ROBOT_NAME', 'default')}.yaml" + + bl.node( + "bitbots_odometry", + "motion_odometry", + "motion_odometry", + param_files=bl.find("bitbots_odometry", config_file, "config"), + params={ + "base_link_frame": f"{tf_prefix}base_link", + "r_sole_frame": f"{tf_prefix}r_sole", + "l_sole_frame": f"{tf_prefix}l_sole", + "odom_frame": f"{tf_prefix}odom", + }, + use_sim_time=sim, + ) diff --git a/src/bitbots_navigation/bitbots_path_planning/launch/path_planning.launch b/src/bitbots_navigation/bitbots_path_planning/launch/path_planning.launch deleted file mode 100755 index 043ddf64b8..0000000000 --- a/src/bitbots_navigation/bitbots_path_planning/launch/path_planning.launch +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/bitbots_navigation/bitbots_path_planning/launch/path_planning.launch.py b/src/bitbots_navigation/bitbots_path_planning/launch/path_planning.launch.py new file mode 100644 index 0000000000..d7713aa218 --- /dev/null +++ b/src/bitbots_navigation/bitbots_path_planning/launch/path_planning.launch.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +import os + +from better_launch import BetterLaunch, launch_this + + +@launch_this +def path_planning(sim: bool = False): + """ + Parameters + ---------- + sim : bool + true: activates simulation time + """ + bl = BetterLaunch() + + config_file = f"path_planning_parameters_{os.environ.get('ROBOT_NAME', 'default')}.yaml" + + bl.node( + "bitbots_path_planning", + "path_planning", + "bitbots_path_planning", + param_files=bl.find("bitbots_path_planning", config_file, "config"), + use_sim_time=sim, + ) diff --git a/src/bitbots_navigation/bitbots_path_planning/setup.py b/src/bitbots_navigation/bitbots_path_planning/setup.py index dd2b07cfac..f48e31f5ab 100644 --- a/src/bitbots_navigation/bitbots_path_planning/setup.py +++ b/src/bitbots_navigation/bitbots_path_planning/setup.py @@ -18,7 +18,7 @@ ("share/ament_index/resource_index/packages", ["resource/" + package_name]), ("share/" + package_name, ["package.xml"]), ("share/" + package_name + "/config", glob.glob("config/*.yaml")), - ("share/" + package_name + "/launch", glob.glob("launch/*.launch")), + ("share/" + package_name + "/launch", glob.glob("launch/*.launch.py")), ], install_requires=["setuptools"], extras_require={ diff --git a/src/bitbots_robot/piplus_description/launch/rviz.launch b/src/bitbots_robot/piplus_description/launch/rviz.launch deleted file mode 100644 index 191ab9624c..0000000000 --- a/src/bitbots_robot/piplus_description/launch/rviz.launch +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/src/bitbots_robot/piplus_description/launch/rviz.launch.py b/src/bitbots_robot/piplus_description/launch/rviz.launch.py new file mode 100644 index 0000000000..3eebab64a7 --- /dev/null +++ b/src/bitbots_robot/piplus_description/launch/rviz.launch.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def rviz(): + bl = BetterLaunch() + bl.node( + "rviz2", + "rviz2", + "rviz2", + cmd_args=["-d", bl.find("piplus_description", "piplus.rviz", "config")], + log_level=None, + ) diff --git a/src/bitbots_robot/piplus_description/launch/standalone.launch b/src/bitbots_robot/piplus_description/launch/standalone.launch deleted file mode 100644 index 0660e7f645..0000000000 --- a/src/bitbots_robot/piplus_description/launch/standalone.launch +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/src/bitbots_robot/piplus_description/launch/standalone.launch.py b/src/bitbots_robot/piplus_description/launch/standalone.launch.py new file mode 100644 index 0000000000..0e327612e4 --- /dev/null +++ b/src/bitbots_robot/piplus_description/launch/standalone.launch.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this +from better_launch.convenience import joint_state_publisher + + +@launch_this +def standalone(js_pub: bool = True): + """ + Parameters + ---------- + js_pub : bool + Whether to run the joint state publisher + """ + bl = BetterLaunch() + + bl.include("piplus_description", "rviz.launch.py") + bl.include("bitbots_parameter_blackboard", "parameter_blackboard.launch.py") + bl.include("bitbots_robot_description", "load_robot_description.launch.py") + + # We do not have a robot connected, so publish fake joint states + if js_pub: + joint_state_publisher( + use_gui=True, + node_name="joint_state_publisher_gui", + anonymous=False, + params={"rate": 100}, + ) + bl.node("bitbots_utils", "dummy_imu.py", "dummy_imu") diff --git a/src/bitbots_simulation/bitbots_mujoco_sim/launch/simulator.launch b/src/bitbots_simulation/bitbots_mujoco_sim/launch/simulator.launch deleted file mode 100644 index 8c0e6c5605..0000000000 --- a/src/bitbots_simulation/bitbots_mujoco_sim/launch/simulator.launch +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/src/bitbots_simulation/bitbots_mujoco_sim/launch/simulator.launch.py b/src/bitbots_simulation/bitbots_mujoco_sim/launch/simulator.launch.py new file mode 100644 index 0000000000..087d3a3fbc --- /dev/null +++ b/src/bitbots_simulation/bitbots_mujoco_sim/launch/simulator.launch.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +import os + +from better_launch import BetterLaunch, launch_this + + +@launch_this +def simulator(web: bool = False): + """ + Parameters + ---------- + web : bool + Use web-based mjviser viewer instead of the native MuJoCo viewer + """ + bl = BetterLaunch() + + if web: + # The web viewer runs headless (no X server), so MuJoCo needs to render the cameras via EGL instead of GLFW + os.environ["MUJOCO_GL"] = "egl" + + bl.node( + "bitbots_mujoco_sim", + "sim", + "sim_interface", + params={ + "use_namespace": False, + "web": web, + }, + ) diff --git a/src/bitbots_simulation/bitbots_mujoco_sim/setup.py b/src/bitbots_simulation/bitbots_mujoco_sim/setup.py index cc6a68f7fb..b81b9143ce 100644 --- a/src/bitbots_simulation/bitbots_mujoco_sim/setup.py +++ b/src/bitbots_simulation/bitbots_mujoco_sim/setup.py @@ -12,7 +12,7 @@ data_files=[ ("share/ament_index/resource_index/packages", ["resource/" + package_name]), ("share/" + package_name, ["package.xml"]), - ("share/" + package_name + "/launch", glob.glob("launch/*.launch.py") + glob.glob("launch/*.launch")), + ("share/" + package_name + "/launch", glob.glob("launch/*.launch.py")), ("share/" + package_name + "/xml", glob.glob("xml/*.xml")), ("share/" + package_name + "/xml/assets", glob.glob("xml/assets/*.png")), ("share/" + package_name + "/xml/assets/ball", glob.glob("xml/assets/ball/*.png")), diff --git a/src/bitbots_team_communication/bitbots_team_communication/config/team_communication_config.yaml b/src/bitbots_team_communication/bitbots_team_communication/config/team_communication_config.yaml index 1a93cc5c2a..68da9dde90 100644 --- a/src/bitbots_team_communication/bitbots_team_communication/config/team_communication_config.yaml +++ b/src/bitbots_team_communication/bitbots_team_communication/config/team_communication_config.yaml @@ -20,7 +20,7 @@ team_comm: # Rate of published messages in Hz rate: 2 # Increase rate by this factor when kicking - increase_rate_during_kick_factor: 1 + increase_rate_during_kick_factor: 10 # Always publish during kick instead of only on switch to kicking always_publish_during_kick: false diff --git a/src/bitbots_team_communication/bitbots_team_communication/launch/team_comm.launch b/src/bitbots_team_communication/bitbots_team_communication/launch/team_comm.launch deleted file mode 100644 index 2bb22f9c49..0000000000 --- a/src/bitbots_team_communication/bitbots_team_communication/launch/team_comm.launch +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/bitbots_team_communication/bitbots_team_communication/launch/team_comm.launch.py b/src/bitbots_team_communication/bitbots_team_communication/launch/team_comm.launch.py new file mode 100644 index 0000000000..68c9aa34c3 --- /dev/null +++ b/src/bitbots_team_communication/bitbots_team_communication/launch/team_comm.launch.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def team_comm(sim: bool = False): + """ + Parameters + ---------- + sim : bool + true: activates simulation time + """ + bl = BetterLaunch() + + bl.node( + "bitbots_team_communication", + "team_comm.py", + "team_comm", + param_files=bl.find("bitbots_team_communication", "team_communication_config.yaml", "config"), + use_sim_time=sim, + ) diff --git a/src/bitbots_team_communication/bitbots_team_communication/launch/team_comm_standalone.launch b/src/bitbots_team_communication/bitbots_team_communication/launch/team_comm_standalone.launch deleted file mode 100644 index bbc5d6cf70..0000000000 --- a/src/bitbots_team_communication/bitbots_team_communication/launch/team_comm_standalone.launch +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/src/bitbots_team_communication/bitbots_team_communication/launch/team_comm_standalone.launch.py b/src/bitbots_team_communication/bitbots_team_communication/launch/team_comm_standalone.launch.py new file mode 100644 index 0000000000..56c201f4d3 --- /dev/null +++ b/src/bitbots_team_communication/bitbots_team_communication/launch/team_comm_standalone.launch.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def team_comm_standalone(sim: bool = False): + """ + Parameters + ---------- + sim : bool + true: activates simulation time + """ + bl = BetterLaunch() + + bl.include("bitbots_parameter_blackboard", "parameter_blackboard.launch.py") + + bl.node( + "bitbots_team_communication", + "team_comm.py", + "team_comm", + param_files=bl.find("bitbots_team_communication", "team_communication_config.yaml", "config"), + use_sim_time=sim, + ) diff --git a/src/bitbots_team_communication/bitbots_team_communication/launch/team_comm_test_marker.launch b/src/bitbots_team_communication/bitbots_team_communication/launch/team_comm_test_marker.launch deleted file mode 100644 index d688e7034e..0000000000 --- a/src/bitbots_team_communication/bitbots_team_communication/launch/team_comm_test_marker.launch +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/src/bitbots_team_communication/bitbots_team_communication/launch/team_comm_test_marker.launch.py b/src/bitbots_team_communication/bitbots_team_communication/launch/team_comm_test_marker.launch.py new file mode 100644 index 0000000000..c9f11ea03f --- /dev/null +++ b/src/bitbots_team_communication/bitbots_team_communication/launch/team_comm_test_marker.launch.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def team_comm_test_marker(rviz: bool = True): + bl = BetterLaunch() + + if rviz: + bl.node( + "rviz2", + "rviz2", + cmd_args=["-d", bl.find("bitbots_team_communication", "team_comm_marker.rviz", "config")], + log_level=None, + ) + + bl.node( + "bitbots_team_communication", + "team_comm_test_marker.py", + "team_comm_test_marker", + param_files=bl.find("bitbots_team_communication", "team_communication_config.yaml", "config"), + ) diff --git a/src/bitbots_vision/docs/index.rst b/src/bitbots_vision/docs/index.rst index 4f60f89e23..0db38eeabc 100644 --- a/src/bitbots_vision/docs/index.rst +++ b/src/bitbots_vision/docs/index.rst @@ -14,7 +14,7 @@ To start the vision, use :: - ros2 launch bitbots_vision vision.launch + bl bitbots_vision vision.launch.py The following parameters are available: diff --git a/src/bitbots_vision/launch/vision.launch b/src/bitbots_vision/launch/vision.launch deleted file mode 100644 index 7c313ce312..0000000000 --- a/src/bitbots_vision/launch/vision.launch +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - diff --git a/src/bitbots_vision/launch/vision.launch.py b/src/bitbots_vision/launch/vision.launch.py new file mode 100644 index 0000000000..8784fb0967 --- /dev/null +++ b/src/bitbots_vision/launch/vision.launch.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def vision(sim: bool = False, debug: bool = False): + """ + Parameters + ---------- + sim : bool + true: activates simulation time + debug : bool + true: activates publishing of the debug image + """ + bl = BetterLaunch() + + bl.node( + "bitbots_vision", + "vision", + "bitbots_vision", + param_files=bl.find("bitbots_vision", "visionparams.yaml", "config"), + params={"components.debug_active": debug}, + use_sim_time=sim, + ) diff --git a/src/bitbots_world_model/bitbots_ball_filter/launch/ball_filter.launch b/src/bitbots_world_model/bitbots_ball_filter/launch/ball_filter.launch deleted file mode 100644 index 7f4fbd4054..0000000000 --- a/src/bitbots_world_model/bitbots_ball_filter/launch/ball_filter.launch +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/bitbots_world_model/bitbots_ball_filter/launch/ball_filter.launch.py b/src/bitbots_world_model/bitbots_ball_filter/launch/ball_filter.launch.py new file mode 100644 index 0000000000..6b7cdc5240 --- /dev/null +++ b/src/bitbots_world_model/bitbots_ball_filter/launch/ball_filter.launch.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def ball_filter(sim: bool = False): + bl = BetterLaunch() + bl.node( + "bitbots_ball_filter", + "ball_filter", + "bitbots_ball_filter", + use_sim_time=sim, + ) diff --git a/src/bitbots_world_model/bitbots_ball_filter/setup.py b/src/bitbots_world_model/bitbots_ball_filter/setup.py index c98cbb296f..dd8cfdb104 100644 --- a/src/bitbots_world_model/bitbots_ball_filter/setup.py +++ b/src/bitbots_world_model/bitbots_ball_filter/setup.py @@ -18,7 +18,7 @@ ("share/ament_index/resource_index/packages", ["resource/" + package_name]), ("share/" + package_name, ["package.xml"]), ("share/" + package_name + "/config", glob.glob("config/*.yaml")), - ("share/" + package_name + "/launch", glob.glob("launch/*.launch")), + ("share/" + package_name + "/launch", glob.glob("launch/*.launch.py")), ], install_requires=[ "launch", diff --git a/src/bitbots_world_model/bitbots_robot_filter/launch/robot_filter.launch b/src/bitbots_world_model/bitbots_robot_filter/launch/robot_filter.launch deleted file mode 100644 index 8936dd786e..0000000000 --- a/src/bitbots_world_model/bitbots_robot_filter/launch/robot_filter.launch +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/bitbots_world_model/bitbots_robot_filter/launch/robot_filter.launch.py b/src/bitbots_world_model/bitbots_robot_filter/launch/robot_filter.launch.py new file mode 100644 index 0000000000..aa96883c2f --- /dev/null +++ b/src/bitbots_world_model/bitbots_robot_filter/launch/robot_filter.launch.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def robot_filter(sim: bool = False): + bl = BetterLaunch() + bl.node( + "bitbots_robot_filter", + "filter", + "bitbots_robot_filter", + param_files=bl.find("bitbots_robot_filter", "params.yaml", "config"), + use_sim_time=sim, + ) diff --git a/src/bitbots_world_model/bitbots_robot_filter/setup.py b/src/bitbots_world_model/bitbots_robot_filter/setup.py index ce4c50c382..31244e2bcd 100644 --- a/src/bitbots_world_model/bitbots_robot_filter/setup.py +++ b/src/bitbots_world_model/bitbots_robot_filter/setup.py @@ -12,7 +12,7 @@ ("share/ament_index/resource_index/packages", ["resource/" + package_name]), ("share/" + package_name, ["package.xml"]), ("share/" + package_name + "/config", glob.glob("config/*.yaml")), - ("share/" + package_name + "/launch", glob.glob("launch/*.launch")), + ("share/" + package_name + "/launch", glob.glob("launch/*.launch.py")), ], install_requires=["setuptools"], zip_safe=True, diff --git a/src/lib/better_launch/.github/manifest.xml b/src/lib/better_launch/.github/manifest.xml new file mode 100644 index 0000000000..181f873455 --- /dev/null +++ b/src/lib/better_launch/.github/manifest.xml @@ -0,0 +1,37 @@ + + better_launch + + A replacement for the cumbersome ROS2 launch system, allowing you to write short and convenient launch files without dozens of imports and nested classes. + + Nikolas Dahn/nikolas.dahn@dfki.de + Nikolas Dahn/nikolas.dahn@dfki.de + + MIT + https://git.hb.dfki.de/ric-maritime/solutions/tools/better_launch + docs/assets/images/logo.png + + + + + middleware + ros2 + launch + + + 0 + single project + + + active + + + + + python + ros2 + gui + + \ No newline at end of file diff --git a/src/lib/better_launch/.github/workflows/joss.yml b/src/lib/better_launch/.github/workflows/joss.yml new file mode 100644 index 0000000000..4e485563b3 --- /dev/null +++ b/src/lib/better_launch/.github/workflows/joss.yml @@ -0,0 +1,30 @@ +name: Draft PDF +on: + push: + paths: + - docs/paper.* + - .github/workflows/joss.yml + +jobs: + paper: + runs-on: ubuntu-latest + name: Paper Draft + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Build draft PDF + uses: openjournals/openjournals-draft-action@master + with: + journal: joss + paper-path: docs/paper.md + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: paper + # Note: this should be the same directory as the input paper.md + path: docs/paper.pdf + - name: Commit PDF to repository + uses: EndBug/add-and-commit@v9 + with: + message: '(auto) Paper PDF Draft' + add: 'docs/paper.pdf' diff --git a/src/lib/better_launch/.github/workflows/main.yml b/src/lib/better_launch/.github/workflows/main.yml new file mode 100644 index 0000000000..dbc7296f6e --- /dev/null +++ b/src/lib/better_launch/.github/workflows/main.yml @@ -0,0 +1,67 @@ +name: Run Unit Test via Pytest + +on: + push: + branches: + - main + +jobs: + build: + name: Deploy docs + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout main + uses: actions/checkout@v1 + + - name: Deploy docs + uses: mhausenblas/mkdocs-deploy-gh-pages@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REQUIREMENTS: docs/requirements.txt + + build-and-test: + name: Build and Test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4.1.6 + with: + path: workspace + + - name: Setup workspace + uses: ichiro-its/ros2-ws-action/setup@v1.0.0 + with: + distro: jazzy + + - name: Build workspace + uses: ichiro-its/ros2-ws-action/build@v1.0.0 + + - name: Install dependencies + run: | + # launch-ros is not included by default + sudo apt-get install -y \ + ros-jazzy-launch-ros \ + ros-jazzy-std-msgs \ + ros-jazzy-examples-rclpy-minimal-publisher \ + ros-jazzy-examples-rclpy-minimal-subscriber \ + ros-jazzy-composition \ + ros-jazzy-examples-rclcpp-minimal-composition + python -m pip install --upgrade pip + pip install pytest + pip install --break-system-packages -r workspace/requirements.txt + + - name: Run tests + shell: bash + run: | + # Explicitly source the ROS2 environment so common packages can be found + source /opt/ros/jazzy/setup.bash + source install/setup.bash + colcon test --event-handlers console_cohesion+ \ + --pytest-with-coverage \ + --return-code-on-test-failure \ + --packages-select better_launch + # The tests are a bit unreliable at the moment + continue-on-error: true diff --git a/src/lib/better_launch/.gitignore b/src/lib/better_launch/.gitignore new file mode 100644 index 0000000000..71bb234750 --- /dev/null +++ b/src/lib/better_launch/.gitignore @@ -0,0 +1,171 @@ +.vscode +examples/test.launch.py + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +./build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/src/lib/better_launch/.gitrepo b/src/lib/better_launch/.gitrepo new file mode 100644 index 0000000000..d050ecc391 --- /dev/null +++ b/src/lib/better_launch/.gitrepo @@ -0,0 +1,12 @@ +; DO NOT EDIT (unless you know what you are doing) +; +; This subdirectory is a git "subrepo", and this file is maintained by the +; git-subrepo command. See https://github.com/ingydotnet/git-subrepo#readme +; +[subrepo] + remote = https://github.com/dfki-ric/better_launch.git + branch = main + commit = 99aea170405745f342df1fa83a1a92810598c9ec + parent = dadf36c3e27f3a8ada153c8bb6d867e852c41c07 + method = merge + cmdver = 0.4.9 diff --git a/src/lib/better_launch/CHANGELOG.rst b/src/lib/better_launch/CHANGELOG.rst new file mode 100644 index 0000000000..3a776b3e24 --- /dev/null +++ b/src/lib/better_launch/CHANGELOG.rst @@ -0,0 +1,14 @@ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Changelog for package better_launch +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Forthcoming +------------------ +* Fixed nodes not capturing all process output +* Fixed rare race condition in ros2_launch_wrapper + +1.0 (2025-07-29) +------------------- +* All examples are working, performance is good, documentation is complete, cats are happy +* Author: Nikolas Dahn +* Original contributors: Prithvi Sanghamreddy, Sebastian Kasperski, Tom Creutz diff --git a/src/lib/better_launch/CITATION.cff b/src/lib/better_launch/CITATION.cff new file mode 100644 index 0000000000..1cbe99eb58 --- /dev/null +++ b/src/lib/better_launch/CITATION.cff @@ -0,0 +1,27 @@ +cff-version: "1.2.0" +authors: +- family-names: Dahn + given-names: Nikolas + orcid: "https://orcid.org/0000-0003-2262-2989" +doi: 10.5281/zenodo.18414064 +message: If you use this software, please cite our article in the + Journal of Open Source Software. +preferred-citation: + authors: + - family-names: Dahn + given-names: Nikolas + orcid: "https://orcid.org/0000-0003-2262-2989" + date-published: 2026-02-25 + doi: 10.21105/joss.08958 + issn: 2475-9066 + issue: 118 + journal: Journal of Open Source Software + publisher: + name: Open Journals + start: 8958 + title: "better_launch: A better replacement for the ROS2 launch + system" + type: article + url: "https://joss.theoj.org/papers/10.21105/joss.08958" + volume: 11 +title: "better_launch: A better replacement for the ROS2 launch system" diff --git a/src/lib/better_launch/CMakeLists.txt b/src/lib/better_launch/CMakeLists.txt new file mode 100644 index 0000000000..6a336195f3 --- /dev/null +++ b/src/lib/better_launch/CMakeLists.txt @@ -0,0 +1,37 @@ +cmake_minimum_required(VERSION 3.5) +project(better_launch) + +find_package(ament_cmake REQUIRED) +find_package(ament_cmake_python REQUIRED) + +# Run via `colcon test --packages-select better_launch`. +# Examine results with `colcon test-result --all` +if(BUILD_TESTING) + find_package(ament_cmake_pytest REQUIRED) + set(_pytest_tests + tests/test_launcher.py + # Add other test files here + ) + foreach(_test_path ${_pytest_tests}) + get_filename_component(_test_name ${_test_path} NAME_WE) + ament_add_pytest_test(${_test_name} ${_test_path} + APPEND_ENV PYTHONPATH=${CMAKE_CURRENT_BINARY_DIR} + TIMEOUT 60 + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + ) + endforeach() +endif() + +install(DIRECTORY examples + DESTINATION share/${PROJECT_NAME}) + +# Install the bl script that should be used instead of "ros2 launch/run" +install(PROGRAMS bin/bl DESTINATION bin) + +# Shell completion for the bl script +ament_environment_hooks("${CMAKE_CURRENT_SOURCE_DIR}/hooks/bl_shell_completion.bash") +ament_environment_hooks("${CMAKE_CURRENT_SOURCE_DIR}/hooks/bl_shell_completion.zsh") +ament_environment_hooks("${CMAKE_CURRENT_SOURCE_DIR}/hooks/bl_shell_completion.fish") + +ament_python_install_package(${PROJECT_NAME}) +ament_package() diff --git a/src/lib/better_launch/CONTRIBUTING.md b/src/lib/better_launch/CONTRIBUTING.md new file mode 100644 index 0000000000..f8973cc88f --- /dev/null +++ b/src/lib/better_launch/CONTRIBUTING.md @@ -0,0 +1,48 @@ +# Contributing to better_launch +Please inform me as early as possible about your planned developments. You may have noticed that *better_launch* is somewhat opinionated and I will be picky about what to include :) An easy way is to open an issue in which you explain what you are trying to do. + +## Issues +Any issues should be reported on the *better_launch* [issue tracker](https://github.com/dfki-ric/better_launch/issues). This is also the preferred way of getting support, making feature requests, etc. Keep your post short and concise, I have other books to read :) I will ask clarifying questions as needed. + +## Pull Requests +The preferred way to contribute to *better_launch* is to fork the [repository](https://github.com/dfki-ric/better_launch) on GitHub *and work from the **devel** branch*, then submit a "pull request" (PR): + +1. [Create an account](https://github.com/signup/free) on GitHub if you do not already have one. + +2. Fork the [project repository](https://github.com/dfki-ric/better_launch): click on the 'Fork' button near the top of the page. This creates a copy of the code under your account on the GitHub server. + +3. Clone this copy to your local disk: + + $ git clone -b devel git@github.com:dfki-rc/better_launch.git + +4. Create a branch to hold your changes: + + $ git checkout -b my-feature devel + + and start making changes. Never work in the ``master`` branch! + +5. Work on this copy, on your computer, using Git to do the version + control. When you're done editing, do: + + $ git add modified_files + $ git commit + + to record your changes in Git, then push them to GitHub with:: + + $ git push -u origin my-feature + +Finally, go to the web page of the your fork of the bolero repo, and click 'Pull request' to send your changes to the maintainers for review request. + +## Merge Policy +Developers have to submit pull requests. Those will be reviewed by at least one other developer and merged by the maintainer. New features must be documented and tested. Breaking changes must be discussed and announced in advance with deprecation warnings. + +## Roadmap +- a tool for generating launch graphs from *better_launch* launch files +- more interactions for the TUI like setting live parameters +- something for converting regular ROS2 launch files to *better_launch*. Some people had good experiences using LLMs +- who knows :) + +## Funding +*better_launch* was initiated and is currently developed at the [Robotics Innovation Center](http://robotik.dfki-bremen.de/en/startpage.html) of the [German Research Center for Artificial Intelligence (DFKI)](http://www.dfki.de) in Bremen. + +![dfki-logo](ddocs/assets/images/dfki.png) diff --git a/src/lib/better_launch/LICENSE b/src/lib/better_launch/LICENSE new file mode 100644 index 0000000000..37e832c8b0 --- /dev/null +++ b/src/lib/better_launch/LICENSE @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2025 DFKI, Nikolas Dahn + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/src/lib/better_launch/README.md b/src/lib/better_launch/README.md new file mode 100644 index 0000000000..8450eab1de --- /dev/null +++ b/src/lib/better_launch/README.md @@ -0,0 +1,126 @@ +![Logo](docs/assets/images/logo_text.png) + +> [!TIP] +> Just looking for the [documentation](https://dfki-ric.github.io/better_launch/)? +> We also have various [examples](examples/)! + +--- + +# 🧭 About +Let's face it: ROS2 has been a severe downgrade in terms of usability compared to ROS1. While there are many considerable improvements, the current launch system is borderline unusable. + +*better_launch* is what I wish ROS2 launch would be: intuitive to use, simple to understand, easy to remember. This is why *better_launch* is **not** yet another abstraction layer over ROS2 launch; it is a **full replacement** with no required dependencies on the existing launch system. + +Instead of dozens of imports and class instances for even the most basic tasks, your launchfiles could look as simple and beautiful as this: + +```python +from better_launch import BetterLaunch, launch_this + +@launch_this +def my_main(enable_x: bool = True): + """This is how nice your launchfiles could be! + """ + bl = BetterLaunch() + + if enable_x: + bl.node( + "examples_rclpy_minimal_publisher", + "publisher_local_function", + "example_publisher", + ) + + # Include other launchfiles, even regular ROS2 launchfiles! + bl.include("better_launch", "ros2_turtlesim.launch.py") +``` + +```bash +# You can use `ros2 launch`, too, but `bl` is better :) +$> bl my_package my_launch_file.py --enable_x True +``` + +*Do I have your attention? Read on to learn more!* +- [The What and Why](https://dfki-ric.github.io/better_launch/about/why/) +- [Differences to ROS2](https://dfki-ric.github.io/better_launch/about/differences/) +- [Installation](https://dfki-ric.github.io/better_launch/installation/installation/) +- [HowTo](https://dfki-ric.github.io/better_launch/howto/python/) +- [Examples](examples/python) + +# 🧞‍♀️ Highlights + +## 🪄 Complete replacement + +> [!NOTE] +> [All features](https://dfki-ric.github.io/better_launch/about/features/) + +*better_launch* can do everything that ROS2 launchfiles can, but more, with equal or less resources, and better: +- Solve almost every task with 2-3 imports +- Actions are executed in sequence, nodes shutdown in reverse +- Shell autocompletion for launch arguments +- Use natural types instead of strings of lists of dicts of... +- Include regular ROS2 launch files and get included by them + +## 📟 The TUI + +> [!NOTE] +> [Using the TUI](https://dfki-ric.github.io/better_launch/howto/tui/) + +*better_launch* comes with an optional, unobstrusive TUI (terminal user interface) based on [prompt_toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit), which will hover below the log output. + +![TUI](docs/assets/images/tui.png) + +See the single line of shortcuts at the bottom? That's the TUI, and it will never take up more than 3 lines! Despite its simplicity, the TUI allows you a comfortable degree of control over all nodes managed by the *better_launch* process it is running in: +- listing a node's services and topics +- starting and stopping nodes +- triggering lifecycle transitions +- changing the log level +- etc. + +```bash +# Run this line to see it in action! +bl better_launch 02_ui.launch.py +``` + +## ⛱️ TOML launchfiles + +> [!NOTE] +> [Specification](https://dfki-ric.github.io/better_launch/howto/toml/) + +For those with aversions against using a turing-complete programming language to specify system startup - fear not! *better_launch* introduces a new launchfile format based on [TOML](https://toml.io/). + +```toml +enable = true + +[a_simple_cube] +if = "${enable}" +func = "find" +package = "better_launch" +filename = "cube.sdf" + +[print_me_baby] +func = "log" +severity = "info" +message = "Found cube at ${a_simple_cube}" +``` + +Under the hood, TOML launchfiles result in calls to the `BetterLaunch` singleton, but offer a more focused and constrained feature set (limited branching, no loops, etc.). If you are still missing ROS1 XML launchfiles (and substitutions like `${arg my_arg}`), these are for you! + + +# 🌱 Contributions + +> [!IMPORTANT] +> Please [see this document](CONTRIBUTING.md) if you're planning to make PR! + +*Author:* [Nikolas Dahn](https://github.com/ndahn/) + +*Testing & Feedback:* +- [Tom Creutz](https://github.com/tomcreutz) +- [Prithvi Sanghamreddy](https://github.com/Prithvi-Sanghamreddy) +- [Sebastian Kasperski](https://github.com/skasperski) + +*better_launch* was initiated and is currently developed at the [Robotics Innovation Center](http://robotik.dfki-bremen.de/en/startpage.html) of the [German Research Center for Artificial Intelligence (DFKI)](http://www.dfki.de) in Bremen. + +--- + +*Copyright 2026, [DFKI GmbH](http://www.dfki.de) / [Robotics Innovation Center](http://robotik.dfki-bremen.de/en/startpage.html)* + +![dfki-logo](docs/assets/images/dfki.png) \ No newline at end of file diff --git a/src/lib/better_launch/better_launch/__init__.py b/src/lib/better_launch/better_launch/__init__.py new file mode 100644 index 0000000000..c31992696f --- /dev/null +++ b/src/lib/better_launch/better_launch/__init__.py @@ -0,0 +1,17 @@ +__version__ = "1.6.0" + +__all__ = [ + "launch_this", + "BetterLaunch", + "LifecycleStage" +] + +from .launcher import BetterLaunch +from .wrapper import launch_this +from .declarative import launch_toml +from .elements import LifecycleStage +from .ros.logging import LaunchConfig as LogConfig +from . import elements +from . import utils +from . import convenience +from . import gazebo diff --git a/src/lib/better_launch/better_launch/convenience.py b/src/lib/better_launch/better_launch/convenience.py new file mode 100644 index 0000000000..80bc78ddd4 --- /dev/null +++ b/src/lib/better_launch/better_launch/convenience.py @@ -0,0 +1,714 @@ +"""Additional convenience methods that aren't general enough to be added to the main namespace.""" + +__all__ = [ + "rviz", + "read_robot_description", + "joint_state_publisher", + "robot_state_publisher", + "static_transform_publisher", + "spawn_controller_manager", + "spawn_controller", + "record_topics", + "record_topics_from_file", +] + + +from typing import Sequence, Any, Literal, Callable +import os +import subprocess +import json +import yaml +import tempfile + +from better_launch import BetterLaunch +from better_launch.elements import Node + + +def rviz( + package: str = None, + configfile: str = None, + subdir: str = None, + *, + extra_args: list[str] = None, +) -> Node: + """Runs RViz2 with the given config file and optional warning level suppression. + + Parameters + ---------- + package : str, optional + Path to locate the config file in (if one is specified). + configfile : str, optional + Path to the RViz2 configuration file which will be resolved by [BetterLaunch.find][]. Otherwise RViz2 will run with the default config. + subdir : str, optional + A path fragment the config file must be located in. + extra_args : list[str], optional + Additional args to pass to the RViz2 executable. + + Returns + ------- + Node + The spawned node instance. + """ + bl = BetterLaunch.instance() + + args = [] + if configfile: + configfile = bl.find(package, configfile, subdir) + args += ["-d", configfile] + + if extra_args: + args.extend(extra_args) + + # rviz2 doesn't support the --log-level argument nodes usually accept + return bl.node( + "rviz2", "rviz2", "rviz2", anonymous=True, cmd_args=args, log_level=None + ) + + +def read_robot_description( + package: str, + description_file: str, + subdir: str = None, + *, + xacro_args: list[str] = None, +) -> str | None: + """Returns the contents of a robot description after a potential xacro parse. + + The file is resolved using [BetterLaunch.find][]. If the description file ends with `.urdf` and `xacro_args` is not provided, it reads the URDF file directly. Otherwise it runs `xacro` to generate the URDF from a `.xacro` file. + + Parameters + ---------- + package : str + The package where the robot description file is located. May be `None` to use this launch file's package (see [BetterLaunch.find][]). + description_file : str + The name of the robot description file (URDF or XACRO). + subdir : str, optional + A path fragment the description file must be located in. + xacro_args : list of str, optional + Additional arguments to pass to `xacro` when processing `.xacro` files. + + Returns + ------- + str | None + The parsed URDF XML as a string if successful, `None` otherwise. + + Raises + ------ + ValueError + If the xacro command encountered an error while processing the file. + """ + bl = BetterLaunch.instance() + + filepath = bl.find(package, description_file, subdir) + + if not filepath.endswith("xacro") and xacro_args is None: + with open(filepath) as f: + return f.read() + + args = [filepath] + if xacro_args: + args.extend(xacro_args) + + try: + return bl.exec(["xacro", *args]) + except subprocess.CalledProcessError as e: + raise ValueError(f"Xacro failed ({e.returncode}): {e.output}") from e + + +# Don't think this is useful enough to be part of the public api +def _resolve_robot_description( + source: str, + as_topic: bool, + topic: str = "/robot_description", + *, + xacro_args: list[str] = None, +) -> str: + """Prepares a robot description for passing to another node. + + If `source` points to a file, read it. Then, if `as_topic` is True and `source` looks like xml, create a publisher on `topic` and publish the contents. + + Note that if a topic or anything else that does not match the above criteria is passed in, it will be returned verbatim. + + Parameters + ---------- + source : str + A robot description topic, file path, or contents. + as_topic : bool + If True and either a file path or xml contents were passed, publish the robot description on the specified topic. + topic : str, optional + Where to publish the robot description contents. + xacro_args : list[str], optional + Xacro args to be passed to `read_robot_description` if the source is a xacro file. + + Returns + ------- + str + The contents of the robot description if a file was loaded, otherwise `source` as is. + """ + from std_msgs.msg import String + + bl = BetterLaunch() + + if os.path.isfile(source): + source = read_robot_description(None, source, xacro_args=xacro_args) + + if as_topic and source.lstrip().startswith("<"): + source = source.strip() + pub = bl.publisher( + topic, + String, + bl.qos_profile(durability="transient_local"), + ) + pub.publish(String(data=source)) + + return source + + +def joint_state_publisher( + use_gui: bool = False, node_name: str = None, **kwargs +) -> Node: + """Starts a `joint_state_publisher` or `joint_state_publisher_gui` node. + + Parameters + ---------- + use_gui : bool, optional + Whether to use the GUI version of the `joint_state_publisher`. + node_name : str, optional + The name of the node. If not provided the name of the executable will be used. Will be anonymized unless `anonymous=False` is passed. + **kwargs : dict, optional + Additional arguments to pass to the node (e.g. name, remaps, params, etc.). See [BetterLaunch.node][]. + + Returns + ------- + Node + The spawned node instance. + """ + bl = BetterLaunch.instance() + + kwargs.setdefault("anonymous", True) + + if use_gui: + return bl.node( + "joint_state_publisher_gui", + "joint_state_publisher_gui", + node_name or "joint_state_publisher_gui", + **kwargs, + ) + else: + return bl.node( + "joint_state_publisher", + "joint_state_publisher", + node_name or "joint_state_publisher", + **kwargs, + ) + + +def robot_state_publisher( + robot_description: str = None, + *, + node_name: str = None, + pass_by_topic: bool = False, + description_topic: str = "/robot_description", + xacro_args: list[str] = None, + **kwargs, +) -> Node: + """Start a Robot State Publisher node from a robot description. + + Parameters + ---------- + robot_description : str, optional + Robot description the state publisher will use. This can be a urdf/xacro file path (e.g. from [BetterLaunch.find][]), a topic, or an xml string as returned by [read_robot_description][]. See also `pass_by_topic`. If not set it will be read from `/robot_description` (subject to remaps). + node_name : str, optional + The name of the node. If not provided the name of the executable will be used. Will be anonymized unless `anonymous=False` is passed. + xacro_args : list of str, optional + Additional arguments to pass to the Xacro processor when a `.xacro` file was passed. + pass_by_topic : bool, optional + If True and the value provided for `robot_description` is either a file or xml string, the description will be passed to the controller manager via a topic rather than a ROS parameter. On ROS versions before lyrical this feature is disabled. + description_topic : str, optional + The topic under which the robot description will be published if `pass_by_topic` is True. + **kwargs : dict, optional + Additional arguments for the node, such as remappings or parameters. + + Returns + ------- + Node + The spawned node instance. + """ + bl = BetterLaunch.instance() + + if bl.ros_distro_key() < "l": + pass_by_topic = False + + kwargs.setdefault("anonymous", True) + params = kwargs.pop("params", {}) + remaps = kwargs.pop("remaps", {}) + + if not robot_description: + robot_description = "/robot_description" + + robot_description = _resolve_robot_description( + robot_description, + pass_by_topic, + description_topic, + xacro_args=xacro_args, + ) + + if not robot_description.lstrip().startswith("<"): + # Input arg was not a file or xml content string, assume it's a topic + if bl.ros_distro_key() < "l": + # Before lyrical we need to read the topic and pass it as a parameter + rd_msg = bl.receive_message( + robot_description, "std_msgs/msg/String", None, timeout=1.0 + ) + if not rd_msg: + raise ValueError( + f"Failed to read robot description from {robot_description}" + ) + + robot_description = rd_msg.data + params["robot_description"] = robot_description + else: + # On lyrical and higher we can simply forward the assumed topic string + if remaps.get("robot_description") not in (None, description_topic): + raise ValueError( + "Cannot remap robot_description to a different topic when pass_by_topic is True, modify description_topic instead" + ) + + remaps["robot_description"] = robot_description + params["use_robot_description_topic"] = True + elif pass_by_topic: + # We managed to read the description contents and want to pass them by topic + if remaps.get("robot_description") not in (None, description_topic): + raise ValueError( + "Cannot remap robot_description to a different topic when pass_by_topic is True, modify description_topic instead" + ) + + remaps["robot_description"] = description_topic + params["use_robot_description_topic"] = True + else: + # We received description contents that should be passed by parameter instead of topic + params["robot_description"] = robot_description + + node = bl.node( + "robot_state_publisher", + "robot_state_publisher", + node_name, + params=params, + remaps=remaps, + **kwargs, + ) + + return node + + +def static_transform_publisher( + parent_frame: str, + child_frame: str, + pos: Sequence[float] = None, + rot: Sequence[float] = None, +) -> Node: + """Publish a static transform between two frames. + + Parameters + ---------- + parent_frame : str + The parent or source frame. + child_frame : str + The child or target frame. + pos : Sequence[float], optional + The xyz-translation from parent to child frame. You may also pass a sequence of 6 or 7 floats in order to specify a full pose. In this case, `rot` will be ignored. + rot : Sequence[float], optional + The rotation between the parent and child frame. If length is 3, the values are interpreted as roll-pitch-yaw euler angles. If length is 4, an xyzw-quaternion is assumed. + + Returns + ------- + Node + The node running the publisher process. + + Raises + ------ + ValueError + If pos or rot have the wrong length. + """ + args = ["--frame-id", parent_frame, "--child-frame-id", child_frame] + + # Position may also hold the pose + if pos is not None: + args.extend(["--x", pos[0], "--y", pos[1], "--z", pos[2]]) + if len(pos) == 3: + pass + elif len(pos) == 4 and pos[3] == 1.0: + # Homogenous translation vector, it's fine + pass + elif len(pos) in (6, 7): + rot = pos[3:] + else: + raise ValueError("Position has dubious length %d", len(pos)) + + if rot is not None: + if len(rot) == 3: + args.extend(["--roll", rot[0], "--pitch", rot[1], "--yaw", rot[2]]) + elif len(rot) == 4: + args.extend( + ["--qx", rot[0], "--qy", rot[1], "--qz", rot[2], "--qw", rot[3]] + ) + else: + raise ValueError("Rotation has dubious length %d", len(rot)) + + bl = BetterLaunch.instance() + return bl.node( + "tf2_ros", + "static_transform_publisher", + "gazebo_world_tf", + cmd_args=args, + log_level=None, + ) + + +def spawn_controller_manager( + param_files: str | list[str] = None, + robot_description: str = None, + *, + remaps: dict[str, str] = None, + params: dict[str, Any] = None, + cmd_args: list[str] = None, + name: str = "controller_manager", + pass_by_topic: bool = True, + description_topic: str = "/robot_description", + xacro_args: list[str] = None, +) -> Node: + """Spawn a new controller manager. + + Parameters + ---------- + param_files : str | list[str], optional + One or more config files to be read by the controller manager. These will also be passed on to any controllers loaded and are often the only way to provide them with namespaced parameters. + robot_description : str, optional + Robot description to pass to the controller. This can be a urdf/xacro file path (e.g. from [BetterLaunch.find][]), a topic, or an xml string as returned by [read_robot_description][]. See also `pass_by_topic`. If not set the manager's default is used (`~/robot_description`, subject to remaps). + remaps : dict[str, str], optional + Topic remaps for the controller manager, e.g. for the `~/robot_description` topic it usually subscribes to. + params : str | dict[str, Any], optional + The controller manager config to use (typically named `controller.yaml`). If a string is passed it is considered as a path and loaded via [BetterLaunch.load_params][]. + cmd_args: list[str], optional + Additional CLI arguments to pass to the spawner command (e.g. `--load-only`). + name : str, optional + pass_by_topic : bool, optional + If True and the value provided for `robot_description` is either a file or xml string, the description will be passed to the controller manager via a topic rather than a ROS parameter. Automatically enabled on jazzy and newer, where passing by parameter is no longer supported. + description_topic : str, optional + Where to publish the robot description if `pass_by_topic` is True. + xacro_args : list[str], optional + Additional arguments to pass to the Xacro processor when a `.xacro` robot description file was passed. + + The name the controller manager node should use. The rename is qualified and so won't affect controllers spawned later (see [this document](https://control.ros.org/humble/doc/ros2_control/controller_manager/doc/userdoc.html#using-the-controller-manager-in-a-process) for details). Note however that many nodes and CLI programs (e.g. `ros2 control`) expect the manager to be named `controller_manager` and won't work properly otherwise. + + Returns + ------- + Node + The node running the controller manager process. + """ + bl = BetterLaunch.instance() + + # In ROS2 there isn't a central parameter server anymore. However, each ROS2 process stores + # context object with all ros args passed to it. If the process creates new nodes their + # startup arguments are populated from this context object. This way the controller manager + # can store parameters for other controllers even if they are only spawned later. + # See this very helpful summary for details: + # https://github.com/ros-controls/ros2_control/issues/335 + + if params is None: + params = {} + + if robot_description: + # Passing by parameter is not supported in jazzy and onwwards + if bl.ros_distro_key() >= "j": + pass_by_topic = True + + robot_description = _resolve_robot_description( + robot_description, + pass_by_topic, + description_topic, + xacro_args=xacro_args, + ) + + if pass_by_topic: + if remaps is None: + remaps = {} + elif remaps.get("robot_description") not in (None, description_topic): + raise ValueError( + "Cannot remap robot_description to a different topic when pass_by_topic is True, modify description_topic instead" + ) + + remaps["robot_description"] = description_topic + else: + # Pass it as a parameter + params["robot_description"] = robot_description + + else: + if bl.ros_distro_key() < "j": + bl.logger.warning( + "Note that in distros before Jazzy the controller_manager is subscribing to '~/robot_description' by default!" + ) + + return bl.node( + package="controller_manager", + executable="ros2_control_node", + name=name, + remaps=remaps, + params=params, + param_files=param_files, + cmd_args=cmd_args, + # Prevent renaming nodes spawned by the manager + # See https://control.ros.org/humble/doc/ros2_control/controller_manager/doc/userdoc.html#using-the-controller-manager-in-a-process + remap_qualifier="controller_manager", + ) + + +def spawn_controller( + controller: str, + params: str | list[str] | dict[str, Any] = None, + *, + remaps: dict[str, str] = None, + cmd_args: list[str] = None, + manager: str = "controller_manager", +) -> None: # TODO find a way to return an interactable object + """Spawn the specified controller. + + Note that right now there is no way to directly interact with the loaded controller's node due to the limited API ROS2 provides. I will find a way... + + Parameters + ---------- + controller : str + The controller to spwawn. + params : str | list[str] | dict[str, Any], optional + Additional parameters for the controller node. Can be a path to a ROS2 config, a list of paths, or a dict with the actual key-value pairs. In versions of ROS before Jazzy the params will be serialized into a temporary yaml file. + remaps : dict[str, str], optional + Additional remaps specific to the controller. These will be qualified with the controller's name to avoid conflicts. + cmd_args: list[str], optional + Additional CLI arguments to pass to the spawner command (e.g. `--load-only`). + manager : str, optional + The name of the controller_manager node. + """ + bl = BetterLaunch.instance() + process_args = [controller, "--controller-manager", manager] + + if cmd_args: + process_args.extend(cmd_args) + + if isinstance(params, str): + params = bl.load_params(None, params) + + if params: + # Passing controller params directly is only supported in Jazzy and newer, so we + # write them to a yaml instead, then pass them as a param-file + if isinstance(params, dict) and bl.ros_distro_key() < "j": + data = yaml.serialize(params).splitlines() + + tmp = tempfile.NamedTemporaryFile("w+", suffix=".yaml") + bl.logger.warning( + f"ROS2 {bl.ros_distro()} does not support passing params to controllers directly, serializing to {tmp.name} instead" + ) + tmp.writelines(data) + bl.add_shutdown_callback(tmp.close) + + params = tmp.name + + # Decide how to pass the params to the controller + if isinstance(params, dict): + manager_node = bl.query_node(manager, include_foreign=True) + + if not manager_node: + raise ValueError(f'Could not find controller manager "{manager}"') + + # In theory we could pass --controller-ros-args to the spawner and let the spawner + # handle these, but it unfortunately does some very naive string splitting which + # messes up more complex arguments containing e.g. lists. + manager_node.set_live_params( + { + f"{controller}.node_options_args": [ + f"{key}:={json.dumps(val)}" for key, val in params.items() + ] + } + ) + elif isinstance(params, str): + # Usually we'd load the parameters here and pass them to the controller manager, + # but this functionality only exists from jazzy onwards + process_args.extend(["--param-file", params]) + elif isinstance(params, list): + for path in params: + process_args.extend(["--param-file", path]) + else: + raise ValueError(f"Controller params of type {type(params)} not supported") + + if remaps: + if bl.ros_distro_key() < "j": + raise ValueError( + "Passing controller params directly is only supported in Jazzy and newer" + ) + + for key, value in remaps.items(): + # Qualify remaps to avoid accidental remaps for other controllers + process_args.extend( + ["--controller-ros-args", f"-r {controller}:{key}:={value}"] + ) + + # This is NOT a node! Could also use the spawner python implementation directly, but that + # would just introduce another dependency with little benefit. + spawner = bl.find("controller_manager", "spawner") + bl.exec(["python3", spawner] + process_args) + + +def record_topics( + topics: list[str] = None, + predicate: Callable[[str], bool] = None, + *, + bagdir: str = None, + max_bag_duration: int = 0, + max_bag_size: float = 0.0, + recordings: int = 1, + wait: bool = True, + format: Literal["mcap", "sqlite3"] = "mcap", + extra_args: list[str] = None, +) -> None | Node: + """Record a rosbag. + + If topics is empty or not specified all currently published topics are used. The list of topics will be filtered by the predicate if provided. + + Parameters + ---------- + topics : list[str], optional + The ROS topics to record. If not specified all currently known topics will be used instead. + predicate : Callable[[str], bool], optional + A function to decide which topics to record. I suggest to use fnmatch for simple wildcards. + bagdir : str, optional + Where to record the bagfile. If not specified it will use the rosbag default (a timestamped folder in the currend working directory). + max_bag_duration : int, optional + Start recording a new bagfile after recording for X seconds. + max_bag_size : float, optional + Start recording a new bagfile after recording X MB. + recordings : int, optional + Make this many recordings each with the set maximum bag duration/size. Continue until interrupted if <= 0. Multiple recordings can only be done when `wait` is True. + wait : bool, optional + If True, wait for the recording to finish before returning. Otherwise the process will be wrapped in a [elements.Node][] object and returned. Must be True if `recordings` is != 1. + format : Literal["mcap", "sqlite3"], optional + Rosbag recording format, should be mcap or sqlite3. + extra_args : Iterable[str], optional + Additional arguments that will be passed to rosbag. + + Returns + ------- + None | Node + Nothing if wait is True, otherwise a [elements.Node][] object wrapping the recording process. + """ + if recordings != 1 and not wait: + raise ValueError("For multiple recordings wait must be True") + + bl = BetterLaunch() + + if not topics: + topics = [t for t, _ in bl.shared_node.get_topic_names_and_types()] + + cmd = [ + "ros2", + "bag", + "record", + "--storage", + format, + "--max-bag-duration", + int(max_bag_duration), + "--max-bag-size", + int(max_bag_size * 1024 * 1024), + ] + + if bagdir: + cmd.extend(["-o", bagdir]) + + if extra_args: + cmd.extend(extra_args) + + if predicate: + topics = list(filter(predicate, topics)) + + if not topics: + raise ValueError("No topics left to record") + + cmd.extend(topics) + cmd = [str(x) for x in cmd] + count = 0 + + while True and not bl.is_shutdown: + bl.logger.critical(f"\n===== RECORDING {count + 1} =====\n") + if wait: + bl.exec(cmd) + count += 1 + if recordings > 0 and count >= recordings: + break + else: + return bl.process(cmd) + + +def record_topics_from_file( + topic_file: str, + predicate: Callable[[str], bool] = None, + *, + bagdir: str = None, + max_bag_duration: int = 0, + max_bag_size: float = 0, + recordings: int = 1, + wait: bool = True, + format: Literal["mcap", "sqlite3"] = "mcap", + extra_args: list[str] = None, +) -> None | Node: + """Record a rosbag. + + Reads topics to record from a text-file. Empty lines and lines starting with "#" will be ignored. + + Parameters + ---------- + topic_file : str + The file to read the topic list from. + predicate : Callable[[str], bool], optional + A function to decide which topics to record. I suggest to use fnmatch for simple wildcards. + bagdir : str, optional + Where to record the bagfile. If not specified it will use the rosbag default (a timestamped folder in the currend working directory). + topic_file : str, optional + Text file with topics to record. Lines starting with "#" will be ignored. + max_bag_duration : int, optional + Start recording a new bagfile after recording for X seconds. + max_bag_size : float, optional + Start recording a new bagfile after recording X MB. + recordings : int, optional + Make this many recordings each with the set maximum bag duration/size. Continue until interrupted if <= 0. Multiple recordings can only be done when `wait` is True. + wait : bool, optional + If True, wait for the recording to finish before returning. Otherwise the process will be wrapped in a [elements.Node][] object and returned. Must be True if `recordings` is != 1. + format : Literal["mcap", "sqlite3"], optional + Rosbag recording format, should be mcap or sqlite3. + extra_args : Iterable[str], optional + Additional arguments that will be passed to rosbag. + + Returns + ------- + None | Node + Nothing if wait is True, otherwise a [elements.Node][] object wrapping the recording process. + """ + with open(topic_file) as f: + lines = f.readlines() + + topics = [] + for line in lines: + line = line.strip() + if not line or line.startswith("#"): + continue + + topics.append(line) + + return record_topics( + topics, + predicate=predicate, + bagdir=bagdir, + max_bag_duration=max_bag_duration, + max_bag_size=max_bag_size, + recordings=recordings, + wait=wait, + format=format, + extra_args=extra_args, + ) diff --git a/src/lib/better_launch/better_launch/declarative.py b/src/lib/better_launch/better_launch/declarative.py new file mode 100644 index 0000000000..d7b7bcd242 --- /dev/null +++ b/src/lib/better_launch/better_launch/declarative.py @@ -0,0 +1,361 @@ +from typing import Any +import inspect +import contextlib +import logging + +from better_launch import BetterLaunch, convenience, gazebo +from better_launch.wrapper import _exec_launch_func +from better_launch.utils.settings import Colormode, _update_settings +from better_launch.utils.click import DeclaredArg +from better_launch.toml.toml_parser import load as load_toml +from better_launch.toml.substitutions import EvalMode, apply_substitutions + + +current_toml_format_version = 1 + + +def _execute_toml( + toml: dict[str, Any], + eval_mode: EvalMode, + **kwargs, +) -> dict[str, Any]: + """Execute each call table and apply substitutions.""" + if BetterLaunch.instance(): + raise RuntimeError("BetterLaunch has already been initialized") + + # Apply any launch arguments, but prevent overriding call tables + for key, val in kwargs.items(): + toml_val = toml.get(key) + if isinstance(toml_val, dict) and "func" in toml_val: + raise RuntimeError(f"Launcher tried to override TOML call table '{key}'") + + toml[key] = val + + # Initialize the launcher instance + bl = BetterLaunch() + + # + contexts = { + "betterlaunch": { + # instance functions + name: func + for name, func in inspect.getmembers(BetterLaunch, inspect.isfunction) + if not name.startswith("_") + } + | { + # class methods + name: func + for name, func in inspect.getmembers(BetterLaunch, inspect.ismethod) + if not name.startswith("_") + }, + "convenience": { + name: func + for name, func in inspect.getmembers(convenience, inspect.isfunction) + if not name.startswith("_") + }, + "gazebo": { + name: func + for name, func in inspect.getmembers(gazebo, inspect.isfunction) + if not name.startswith("_") + }, + } + results = dict(bl.launch_args) + + def sanitize(data: dict) -> None: + # Remove the comment keys that we kept in order to create help texts and such + data.pop("__comment__", None) + for key, val in toml.items(): + if isinstance(val, dict): + for key in list(val.keys()): + if key.startswith("__comment_"): + del val[key] + + def substitute_all(value: Any): + if isinstance(value, dict): + for key, val in value.items(): + value[key] = substitute_all(val) + elif isinstance(value, list): + for i, item in enumerate(value): + value[i] = substitute_all(item) + elif isinstance(value, str): + new_val = apply_substitutions(value, None, results, eval_mode=eval_mode) + return new_val + + return value + + def exec_request(key: str, req: dict) -> Any: + if "func" not in req: + return + + substitute_all(req) + if not req.pop("if", True): + results[key] = None + return + + if req.pop("unless", False): + results[key] = None + return + + ctx = req.pop("context", "betterlaunch") + if ctx not in contexts: + raise KeyError(f"{key}: '{ctx}' is not a valid context") + + valid_funcs = contexts[ctx] + + # Get the function to execute + func_name = req.pop("func") + if func_name not in valid_funcs: + raise KeyError(f"{key}: func='{func_name}' is not a valid function") + + func = valid_funcs[func_name] + func_sig = inspect.signature(func) + + # Call the function and store the result + children = None + if "children" not in func_sig.parameters: + children = req.pop("children", None) + + bl.logger.debug(f"Calling {ctx}:{func_name} ({req})") + if ctx == "betterlaunch": + if inspect.ismethod(func): + # classmethod + res = func(**req) + else: + # instance function + res = func(bl, **req) + else: + res = func(**req) + + results[key] = res + + if children: + if not isinstance(res, contextlib.AbstractContextManager): + logging.getLogger().warning( + f"{req} has 'children' key, but did not result in a context object - children will be ignored" + ) + return + + with res: + if isinstance(children, list): + children = { + f"{key}.children.{idx}": child + for idx, child in enumerate(children) + } + + if not isinstance(children, dict): + raise ValueError( + f"Children of {key} must be specified as a dict or subtable" + ) + + for subkey, child in children.items(): + sanitize(child) + exec_request(subkey, child) + + # Execute the call tables + sanitize(toml) + for key, val in toml.items(): + if isinstance(val, dict): + exec_request(key, val) + else: + results[key] = val + + return results + + +def _get_toml_args(toml: dict) -> list[DeclaredArg]: + args = [] + + for key, val in toml.items(): + if key.startswith("_"): + continue + + if isinstance(val, dict) and "func" in val: + continue + + if isinstance(val, type): + # no default value + ptype = val + default = DeclaredArg._undefined + else: + ptype = type(val) + default = val + + description = toml.get(f"__comment_{key}__") + + args.append( + DeclaredArg( + key, + ptype, + default, + description, + ) + ) + + return args + + +def launch_toml( + path: str, + launch_args: dict[str, str] = None, + *, + # These should largely mirror the launch_this decorator + ui: bool = None, + colormode: Colormode = None, + print_limit: int = None, + screen_log_level: str | int = None, + screen_log_format: str = None, + file_log_level: str | int = None, + file_log_format: str = None, + use_sim_time: bool = None, + eval_mode: str | EvalMode = None, + join: bool = None, + manage_foreign_nodes: bool = None, + keep_alive: bool = None, + allow_kwargs: bool = None, +) -> None: + """Execute a TOML better_launch launchfile. + + In better_launch TOML launchfiles, most tables will be `call tables`. A call table is a dict that has a `func` key referring to one of the public [BetterLaunch][] member functions. All other attributes will be treated as keyword arguments to that function. Call tables are executed in the order they appear in the launch file, and the result of calling their associated function will be stored under the call table's name. + + For example: + + .. code-block:: toml + name = "the-node-of-destiny" + + [my_awesome_node] + func = "node" + package = "my-package" + executable = "my-node" + name = "${name}" + + Substitutions are also possible and use a similar syntax as in ROS1 (as shown for the `name` launch argument above). `if` and `unless` conditions can be added as well. + + All parameters below can be set through the launch file by declaring them on the global scope with a `bl_` prefix (i.e. `ui` becomes `bl_ui`). + + Please see the documentation for full details. + + Parameters + ---------- + path : str + Path to the TOML launchfile to execute. + launch_args : dict[str, str], optional + values for launch arguments declared by the launchfile. + eval_mode : Literal["full", "literal", "none"], optional + How to treat `eval` substitutions. + ui : bool, optional + Whether to start the better_launch TUI. Superseded by the `BL_UI` environment variable and the `--bl_ui_override` argument. + allow_kwargs : bool, optional + Whether additional launch arguments are allowed. + colormode : Colormode, optional + Decides what colors will be used for: + * default: one color per log severity level and a single color for all message sources + * severity: one color per log severity, don't colorize message sources + * source: one color per message source, don't colorize log severity + * none: don't colorize anything + * rainbow: colorize log severity and give each message source its own color + Superseded by the `BL_COLORMODE` environment variable and the `--bl_colormode_override` argument. + print_limit : int, optional + Limit the length of messages printed to the screen. + screen_log_level : str | int, optional + The minimum level for log messages to be printed to the terminal/screen. Can be either "info", "warning", "error", "critical", or an arbitrary integer (e.g. logging.WARNING). + screen_log_format : str, optional + Customize how log output will be formatted when printing it to the screen. Will be overridden by the `BL_SCREEN_LOG_FORMAT` environment variable. See [PrettyLogFormatter][utils.better_logging.PrettyLogFormatter] for details. + file_log_level : str | int, optional + The minimum level for log messages to be written to the lot file. Can be either "info", "warning", "error", "critical", or an arbitrary integer (e.g. logging.WARNING). + file_log_format : str, optional + Customize how log output will be formatted when writing it to a file. Will be overridden by the `BL_FILE_LOG_FORMAT` environment variable. See [PrettyLogFormatter][utils.better_logging.PrettyLogFormatter] for details. + manage_foreign_nodes : bool, optional + If True, the TUI will also include node processes not started by this process. Has no effect if the TUI is not started. + join : bool, optional + If True, join the better_launch process. Has no effect when ui == True. + keep_alive : bool, optional + If True, keep the process alive even when all nodes have stopped. + """ + toml: dict = load_toml(path) + declared_args = _get_toml_args(toml) + docstring = toml.get("__comment__") + + toml_format = int(toml.get("bl_toml_format", current_toml_format_version)) + if toml_format != current_toml_format_version: + print(f"Warning: TOML launch file has unexpected format version {toml_format}") + + if toml_format == current_toml_format_version: + pass + # elif toml_format == some_previous_version: ... + + if not BetterLaunch.is_included(): + if ui is None and "bl_ui" in toml: + ui = toml.get("bl_ui") + if isinstance(ui, str): + ui = bool(ui.lower() in ("true", "enable", "1")) + + if colormode is None and "bl_colormode" in toml: + colormode = Colormode[toml["bl_colormode"]] + + if print_limit is None and "bl_print_limit" in toml: + print_limit = int(toml["bl_print_limit"]) + + if screen_log_level is None and "bl_screen_log_level" in toml: + screen_log_level = int(toml["bl_screen_log_level"]) + + if screen_log_format is None and "bl_screen_log_format" in toml: + screen_log_format = toml["bl_screen_log_format"] + + if file_log_level is None and "bl_file_log_level" in toml: + file_log_level = int(toml["bl_file_log_level"]) + + if file_log_format is None and "bl_file_log_format" in toml: + file_log_format = toml["bl_file_log_format"] + + if use_sim_time is None and "use_sim_time" in toml: + use_sim_time = toml["use_sim_time"] + + _update_settings( + ui=ui, + colormode=colormode, + print_limit=print_limit, + screen_log_level=screen_log_level, + screen_log_format=screen_log_format, + file_log_level=file_log_level, + file_log_format=file_log_format, + use_sim_time=use_sim_time, + ) + + if join is None: + join = toml.get("bl_join", True) + + if manage_foreign_nodes is None: + manage_foreign_nodes = toml.get("bl_manage_foreign_nodes", False) + + if keep_alive is None: + keep_alive = toml.get("bl_keep_alive", False) + + if allow_kwargs is None: + allow_kwargs = toml.get("bl_allow_kwargs", False) + + if eval_mode is None: + eval_mode = EvalMode(toml.get("bl_eval_mode", "none")) + elif isinstance(eval_mode, str): + eval_mode = EvalMode(eval_mode) + + argv = [] + if launch_args: + for key, arg in launch_args.items(): + if arg is not None: + argv.extend([f"--{key}", str(arg)]) + + def launch_func(*args, **kwargs): + _execute_toml(toml, eval_mode=eval_mode, **kwargs) + + _exec_launch_func( + launch_func, + declared_args, + docstring, + launchfile=path, + manage_foreign_nodes=manage_foreign_nodes, + join=join, + keep_alive=keep_alive, + # Not useful for the launchfile, but some node may consume the extra args + allow_kwargs=allow_kwargs, + _argv=argv, + ) diff --git a/src/lib/better_launch/better_launch/elements/__init__.py b/src/lib/better_launch/better_launch/elements/__init__.py new file mode 100644 index 0000000000..85e1488f77 --- /dev/null +++ b/src/lib/better_launch/better_launch/elements/__init__.py @@ -0,0 +1,24 @@ +from .group import Group +from .abstract_node import AbstractNode +from .live_params_mixin import LiveParamsMixin +from .lifecycle_manager import LifecycleStage, LifecycleManager +from .node import Node +from .foreign_node import ( + ForeignNode, + find_process_for_node, + find_ros2_node_processes, + find_foreign_nodes, + get_package_for_path, +) +from .composer import Composer, Component +from .ros2_launch_wrapper import Ros2LaunchWrapper + +__all__ = [ + "Group", + "Node", + "LifecycleStage", + "LifecycleManager", + "Composer", + "Component", + "ForeignNode", +] diff --git a/src/lib/better_launch/better_launch/elements/abstract_node.py b/src/lib/better_launch/better_launch/elements/abstract_node.py new file mode 100644 index 0000000000..4fa19e5d77 --- /dev/null +++ b/src/lib/better_launch/better_launch/elements/abstract_node.py @@ -0,0 +1,490 @@ +from typing import Any, Iterable +import signal +import time +from fnmatch import fnmatch + +import better_launch.ros.logging as roslog +from better_launch.utils.better_logging import LogSink, configure_logger +from better_launch.elements.lifecycle_manager import LifecycleManager + + +_node_counter = 0 + + +class AbstractNode: + def __init__( + self, + package: str, + executable: str, + name: str, + namespace: str, + remaps: dict[str, str] = None, + params: str | dict[str, Any] = None, + param_files: str | list[str] = None, + *, + output: LogSink | Iterable[LogSink] | Iterable[str] | str = LogSink.SCREEN, + ): + """Base class for all node-like objects. + + Parameters + ---------- + package : str + The package this node can be found in. + executable : str + How the node can be executed. Not necessarily an executable file object. + name : str + This node's name. If it is a ROS node it should be how the node registers with ROS. + namespace : str + The node's namespace. Must be absolute, i.e. start with a '/'. + remaps : dict[str, str], optional + Topic remaps for this node. + params : dict[str, Any], optional + Node parameters. If a string is passed it will be lazy loaded with [BetterLaunch.load_params][]. + param_files : str | list[str], optional + Paths to parameter files that will be passed to the node as is. + output : LogSink | Iterable[LogSink] | Iterable[str] | str, optional + Determines if and where this node's output should be directed. Common choices are `screen` to print to terminal, `log` to write to a common log file, `own_log` to write to a node-specific log file, and `none` to not write any output anywhere. See [configure_logger][] for details. + + Raises + ------ + ValueError + If the name is empty or the namespace is not absolute. + """ + # Required so that other mixins are initialized properly + super().__init__() + + if not name: + raise ValueError("Name cannot be empty") + + if not namespace: + namespace = "/" + + if not namespace.startswith("/"): + raise ValueError("namespace must start with a '/'") + + global _node_counter + self._node_id = _node_counter + _node_counter += 1 + + if isinstance(param_files, str): + param_files = [param_files] + + self._pkg = package + self._exec = executable + self._name = name + self._namespace = namespace + self._remaps: dict[str, str] = remaps or {} + self._params: str | dict[str, str] = params or {} + self._param_files: list[str] = param_files or [] + self._lifecycle_manager: LifecycleManager = None + + self.logger = roslog.get_logger(self.fullname) + configure_logger(self.logger, output) + + @property + def node_id(self) -> int: + return self._node_id + + @property + def package(self) -> str: + """The package this node can be found in.""" + return self._pkg + + @property + def executable(self) -> str: + """How this node can be executed. This is not required to be an executable file. It's meaning depends on the node implementation.""" + return self._exec + + @property + def name(self) -> str: + """The name of this node. If this represents a ROS node this will also be the name by which it is known in ROS.""" + return self._name + + @property + def namespace(self) -> str: + """This node's namespace.""" + return self._namespace + + @property + def fullname(self) -> str: + """The concatenation of this node's namespace and name. Will always start with '/'.""" + ns = self.namespace.strip("/") + if not ns: + return "/" + self.name + return "/" + ns + "/" + self.name + + @property + def params(self) -> dict[str, Any]: + """The ROS params that were passed to this node. If a string was passed it is assumed to be a filepath and will be loaded with [BetterLaunch.load_params][].""" + if isinstance(self._params, str): + from better_launch import BetterLaunch + + bl = BetterLaunch.instance() + if not bl: + return self._params + + self._params = bl.load_params(None, self._params, qualifier=self.fullname) + + return self._params + + @property + def param_files(self) -> list[str]: + """Any param files that should be passed to the node as paths.""" + return self._param_files + + @property + def remaps(self) -> dict[str, str]: + """Any topic remaps that were passed to this node.""" + return self._remaps + + @property + def is_running(self) -> bool: + """True if the node is currently running.""" + raise NotImplementedError() + + def _flat_params(self, drop_qualifiers: bool = False) -> dict[str, Any]: + """Flattens this node's ROS parameters so they conform to what ROS expects. + + Parameters + ---------- + drop_qualifiers : bool, optional + If True, remove additional node/namespace qualifiers from the returned dict. Qualifiers will still be used to match this node if present, i.e. parameters with qualifiers not matching this node will not be included. + + Returns + ------- + dict[str, Any] + A flattened dict containing param keys separated by '.'s and their values. + + Raises + ------ + ValueError + If any list inside the params contains a dict, although we don't recurse into lists. See `#152 `_ for further details. + """ + ret = {} + + def delve(data: dict[str, Any], path: str): + if isinstance(data, list): + for val in data: + if isinstance(val, dict): + # See the following links for more details: + # https://github.com/ros2/launch_ros/blob/jazzy/launch_ros/launch_ros/utilities/normalize_parameters.py#L98 + # https://answers.ros.org/question/322445/ + raise ValueError("ROS2 does not support lists of dicts :(") + + if isinstance(data, dict): + for key, val in data.items(): + new_key = f"{path}.{key}" if path else key + delve(val, new_key) + else: + rp_idx = path.find("ros__parameters") + if rp_idx >= 0: + qualifier = path[:rp_idx].rstrip("./") + param = path[rp_idx + 15 :].lstrip(".") + + if qualifier: + if not qualifier.startswith("/"): + qualifier = "**/" + qualifier + + if qualifier.endswith("/"): + ns_qualifier = qualifier + node_qualifier = None + else: + ns_qualifier, node_qualifier = qualifier.rsplit("/", maxsplit=1) + if not ns_qualifier: + ns_qualifier = "**" + if node_qualifier in ("*", "**"): + node_qualifier = None + + if not fnmatch(self.namespace, ns_qualifier): + return + + if not drop_qualifiers and node_qualifier: + # On the command line ROS only allows the name for qualification, + # which is fine since we already used the full qualifier for matching + path = f"{node_qualifier}:{param}" + else: + # Only the namespace was qualified and this node matched + path = param + else: + path = param + + ret[path] = data + + delve(self.params, "") + return ret + + def join(self, timeout: float = None) -> None: + """Join this node and return once it is shut down. Return immediately if it is not running. + + Parameters + ---------- + timeout : float, optional + How long to wait in seconds. Wait forever if None. + + Raises + ------ + TimeoutError + If a timeout was set and the node is still running by the time it expires. + """ + raise NotImplementedError() + + def start(self) -> None: + """Start this node. Once this succeeds, [is_running][] will return True.""" + raise NotImplementedError() + + def shutdown( + self, reason: str, signum: int = signal.SIGTERM, timeout: float = 0.0 + ) -> None: + """Shutdown this node. Once this succeeds, [is_running][] will return False. + + Parameters + ---------- + reason : str + A human-readable string describing why this node is being shutdown. + signum : int, optional + The signal that should be send to the node (if supported). + timeout : float, optional + How long to wait for the node to shutdown before returning. Don't wait if timeout is 0.0. Wait forever if timeout is None. + + Raises + ------ + TimeoutError + If a timeout > 0 was set and the node did not shutdown before then. + """ + raise NotImplementedError() + + def is_ros2_connected(self, timeout: float = 0.0) -> bool: + """Check whether this node is registered within ROS. + + Parameters + ---------- + timeout : float, optional + How long to wait for the node to sign up with ROS. Wait forever if None. + + Returns + ------- + bool + True if the node can be discovered by ROS, False otherwise. + """ + # Don't check is_running here as some implementations might use is_ros2_connected to + # determine if the node is running + from better_launch import BetterLaunch + + bl = BetterLaunch.instance() + if not bl: + return None + + # Check if the node shows up in the list of running ROS nodes + try: + now = time.time() + while True: + living_nodes = set( + ns + ("" if ns.endswith("/") else "/") + name + for name, ns in bl.shared_node.get_node_names_and_namespaces() + ) + + if self.fullname in living_nodes: + return True + + if timeout is not None and time.time() >= now + timeout: + return False + + time.sleep(0.1) + except Exception: + # Cannot check if the shared node was shut down + return None + + def is_lifecycle_node(self, timeout: float = 0.0) -> bool: + """Checks if this is a lifecycle node and initializes a : py[LifecycleManager][] if supported and not done so before. + + Note that if you simply want to check whether this node supports lifecycle management right now, check whether [lifecycle][] is None will be considerably cheaper. + + Whether a node supports lifecycle management can only be known from outside once its process is started and it has registered with ROS. When this is called while the node is alive and it supports lifecycle management, a [LifecycleManager][] object will be initialized for it. This will persist even if the node is shutdown, but will obviously no longer provide useful functionality. + + Note that at the time of writing (Jazzy), the ROS node registers with ROS before the lifecycle topics are created. This makes sense of course, but also means that there is a short window where the node is registered with ROS but not a lifecycle node yet. This can be a problem, especially on slower devices like a Raspberry Pi 3. In these cases I advise you follow this pattern: + + .. code:: python + + node = Node(...) + # Wait until the node is registered in ROS + if node.is_ros2_connected(timeout=5.0): + # Give the node some additional time to create its lifecycle topics + if node.is_lifecycle_node(timeout=0.1): + # Now the node can be managed + node.lifecycle.transition(...) + + Parameters + ---------- + timeout : float, optional + How long to wait for the node to reveal its lifecycle capabilities. Wait forever if None. + + Returns + ------- + bool + True if the node supports lifecycle management, False otherwise. + """ + if self._lifecycle_manager is None: + if LifecycleManager.is_lifecycle(self, timeout=timeout): + self._lifecycle_manager = LifecycleManager(self) + + return self._lifecycle_manager is not None + + @property + def lifecycle(self) -> LifecycleManager: + """Returns this node's [LifecyceManager][better_launch.elements.lifecycle_manager.LifecycleManager]. + + **Note:** make sure to call [is_lifecycle_node][] before retrieving this object! + + Returns + ------- + LifecycleManager + The object used for managing this node's lifecycle. Will be None if lifecycle management is not supported or `is_lifecycle_node` has not been called before. + """ + return self._lifecycle_manager + + def get_published_services(self) -> dict[str, list[str]]: + """Get the ROS2 services provided by this node. + + Returns + ------- + dict[str, list[str]] + The service topics and message types. Will be empty if [is_ros2_connected][] is False. + """ + if not self.is_ros2_connected(): + return {} + + from better_launch import BetterLaunch + + bl = BetterLaunch.instance() + services = bl.shared_node.get_service_names_and_types() + fullname = self.fullname + res = {} + + for srv_name, srv_types in services: + if srv_name.startswith(fullname): + res[srv_name] = srv_types + + return res + + def get_published_topics(self) -> dict[str, list[str]]: + """Get the ROS2 topics published by this node. + + Returns + ------- + dict[str, list[str]] + The topics and their message types. Will be empty if [is_ros2_connected][] is False. + """ + if not self.is_ros2_connected(): + return {} + + from better_launch import BetterLaunch + + bl = BetterLaunch.instance() + topics = bl.shared_node.get_publisher_names_and_types_by_node( + self.name, self.namespace + ) + return dict(topics) + + def get_subscribed_topics(self) -> dict[str, list[str]]: + """Get the ROS2 topics this node is subscribed to. + + Returns + ------- + dict[str, list[str]] + The topics and their message types. Will be empty if [is_ros2_connected][] is False. + """ + if not self.is_ros2_connected(): + return {} + + from better_launch import BetterLaunch + + bl = BetterLaunch.instance() + topics = bl.shared_node.get_subscriber_names_and_types_by_node( + self.name, self.namespace + ) + return dict(topics) + + def get_info_sheet(self) -> str: + """Returns a summary of this node's information for display in a terminal. + + Returns + ------- + str + A detailed description of this node. + """ + # ROS2 prints a lot of useless stuff and avoids the things that are interesting most of + # the time, like who is actually subscribed where. Let's fix this! + return "\n".join( + [ + self._get_info_section_general(), + self._get_info_section_config(), + self._get_info_section_ros(), + ] + ) + + def _get_info_section_general(self) -> str: + status = "\x1b[92malive\x1b[0m" if self.is_running else "\x1b[91mdead\x1b[0m" + return f"""\ +\x1b[1m{self.name} ({self.__class__.__name__})\x1b[0m + Status: {status} + Lifecycle: {self.lifecycle.current_stage.name if self.lifecycle else "None"} + Package: {self.package} + Command: {self.executable} + Namespace: {self.namespace} +""" + + def _get_info_section_config(self) -> str: + return f"""\ +\x1b[1mConfig\x1b[0m + Node Args: {self.params} + Remaps: {self.remaps} +""" + + def _get_info_section_ros(self) -> str: + if self.is_ros2_connected(): + from better_launch import BetterLaunch + + shared_node = BetterLaunch.instance().shared_node + + # Topics the node is publishing + pubs = shared_node.get_publisher_names_and_types_by_node( + self.name, self.namespace + ) + pubs.sort() + pubs_text = "" + for topic, types in pubs: + pubs_text += f"\n {topic} [{', '.join(types)}]" + + # Topics the node is subscribed to + subs = shared_node.get_subscriber_names_and_types_by_node( + self.name, self.namespace + ) + subs.sort() + subs_text = "" + for topic, types in subs: + subs_text += f"\n {topic} [{', '.join(types)}]" + + # Provided services + services = shared_node.get_service_names_and_types_by_node( + self.name, self.namespace + ) + services.sort() + services_text = "" + for srv, types in services: + services_text += f"\n {srv} [{', '.join(types)}]" + else: + pubs_text = "" + subs_text = "" + services_text = "" + + # TODO don't use html + return f"""\ +\x1b[1mPublishers:\x1b[0m {pubs_text} + +\x1b[1mSubscriptions:\x1b[0m {subs_text} + +\x1b[1mServices:\x1b[0m {services_text} +""" + + def __repr__(self): + return __class__.__name__ + " " + self.fullname diff --git a/src/lib/better_launch/better_launch/elements/composer.py b/src/lib/better_launch/better_launch/elements/composer.py new file mode 100644 index 0000000000..7cc1e2839f --- /dev/null +++ b/src/lib/better_launch/better_launch/elements/composer.py @@ -0,0 +1,552 @@ +from typing import Any, Iterable +import signal +import time +import re +from threading import Event +from rclpy import Parameter +# NOTE message types are imported late because the composer is not always used + +from better_launch.utils.better_logging import LogSink +from .abstract_node import AbstractNode +from .live_params_mixin import LiveParamsMixin +from .lifecycle_manager import LifecycleStage + + +class Component(AbstractNode, LiveParamsMixin): + def __init__( + self, + composer: "Composer", + package: str, + plugin: str, + name: str, + namespace: str, + *, + output: LogSink | Iterable[LogSink] | Iterable[str] | str = LogSink.SCREEN, + remaps: dict[str, str] = None, + params: str | dict[str, Any] = None, + ): + """Representation of a component, a composable object that can be loaded into a running process. Components will always use their composer's namespace. + + Note that in ROS2 launch files you can reference existing composers by name when creating components. This is a clutch because ROS2 does not have a way to retrieve a reference to an already existing node. Since in better_launch we have actual node instances, referring to composers by name is not supported. Use [BetterLaunch.component` or construct your own component and pass a [Composer][] instance. + + Also note that since components are loaded via a service call there are some additional restrictions on the types of `params`. In particular, they must be compatible with the `ROS2 Parameter message type `_. This is *not* verified on construction. + + In addition, note that new nodes created by a component are using the composer's remaps. See the `related issue `_ in rclcpp. + + .. seealso:: + + `ROS2 About Composition `_ + + Parameters + ---------- + composer : Composer + The composer this component will be associated with. + package : str + The package providing this component. + plugin : str + The special string that can be used for loading the component. + name : str + The name of the component in ROS. + namespace : str + The node's namespace. Must be absolute, i.e. start with a '/'. + remaps : dict[str, str], optional + Tells the node to replace any topics it wants to interact with according to the provided dict. + params : str | dict[str, Any], optional + Any arguments you want to provide to the node. These are the args you would typically have to declare in your launch file. A string will be interpreted as a path to a yaml file which will be lazy loaded using [BetterLaunch.load_params][]. + output : LogSink | Iterable[LogSink] | Iterable[str] | str, optional + Determines if and where this node's output should be directed. Common choices are `screen` to print to terminal, `log` to write to a common log file, `own_log` to write to a node-specific log file, and `none` to not write any output anywhere. See [configure_logger][utils.better_logging.configure_logger] for details. + + Raises + ------ + RuntimeError + If the component does not reside in the same namespace as its composer. + """ + if not namespace.startswith(composer.namespace): + raise ValueError("Components must reside in the same namespace as their composer.") + + super().__init__(package, plugin, name, namespace, remaps, params, output=output) + + self._component_id: int = None + self._composer = composer + + self._terminated_event = Event() + self._terminated_event.set() + + @property + def composer(self) -> "Composer": + """The composer this component is associated with.""" + return self._composer + + @property + def component_id(self) -> int: + """The ID this component got assigned when it was loaded into the composer. Will be None if the component is not loaded.""" + return self._component_id + + @property + def is_loaded(self) -> bool: + """True if the component is loaded, False otherwise.""" + return self._component_id is not None + + @property + def plugin(self) -> str: + """The special string that is used for loading the component. [executable][AbstractNode.executable] will return the same.""" + return self._exec + + @property + def is_running(self) -> bool: + if not self.is_loaded: + return False + + return self.is_ros2_connected() + + def join(self, timeout: float = None) -> None: + self._terminated_event.wait(timeout) + + def start( + self, + use_intra_process_comms: bool = True, + **composer_extra_params, + ) -> None: + """Load this component into its composer. + + Additional keyword arguments will be passed as ROS parameters to the component. + + Parameters + ---------- + use_intra_process_comms : bool, optional + If True, the component will use intra process communication for exchanging messages with other components within the same composer. + """ + self._component_id = self.composer.load_component( + self, + use_intra_process_comms=use_intra_process_comms, + **composer_extra_params, + ) + self._terminated_event.clear() + + def shutdown(self, reason: str, signum: int = signal.SIGTERM, timeout: float = 0.0) -> None: + """Unload this component if it was loaded. + + Parameters + ---------- + reason : str + A human-readable string describing why the component is being unloaded. + signum : int, optional + Ignored for components. + """ + if not self.is_loaded: + return + + if signum == signal.SIGTERM and self._lifecycle_manager: + try: + self._lifecycle_manager.transition(LifecycleStage.FINALIZED) + except Exception as e: + self.logger.warning(f"Lifecycle transition to FINALIZED failed: {e}") + + self.logger.warning(f"Unloading component {self.name}: {reason}") + self.composer.unload_component(self, timeout=timeout) + self._component_id = None + self._terminated_event.set() + + def __repr__(self) -> str: + return f"{self.__class__.__name__} {self.package}/{self.plugin}" + + +class Composer(AbstractNode): + @classmethod + def is_composer(cls, node: AbstractNode, timeout: float = 0.0) -> bool: + """Checks whether a node provides services for loading components. + + For a node to be a composer, it must be running, be registered with ROS and offer the ROS composition services. This method **only** checks whether one of the key services is present. + + If a timeout is specified, the check will be repeated until it succeeds or the specified amount of time has passed. This is to ensure that a freshly started node had enough time to create its topics, especially on slower devices. + + Parameters + ---------- + node : AbstractNode + The node object to check. + timeout : float + How long to wait at most for the composition services to appear. Wait forever if None. + + Returns + ------- + bool + True if the node supports loading components, False otherwise. + """ + now = time.time() + while True: + # Check if the node provides one of the key composition services + services = node.get_published_services() + for srv_name, srv_types in services.items(): + if ( + srv_name == f"{node.fullname}/_container/load_node" + and "composition_interfaces/srv/LoadNode" in srv_types + ): + return True + + if timeout is not None and time.time() > now + timeout: + break + + time.sleep(0.1) + + return False + + def __init__( + self, + wrapped_node: AbstractNode, + *, + component_remaps: dict[str, str] = None, + output: LogSink | Iterable[LogSink] | Iterable[str] | str = LogSink.SCREEN, + ): + """A composer is a special ROS2 node that can host other nodes ([Component][]) within the same process, reducing overhead and enabling efficient intra process communication for message exchange. + + As it is possible to reuse already running composers (even without a reference to the actual process), this is merely a wrapper around another [AbstractNode] providing additional functionality. The wrapped node instance is typically a [Node][] or [ForeignNode][]. See [BetterLaunch.compose][] for the most common use cases. + + Note that new nodes created by a component are using the composer's remaps. See the `related issue `_ in rclcpp. + + .. seealso:: + + `ROS2 About Composition `_ + + Parameters + ---------- + wrapped_node : AbstractNode + A representation of the actual ROS2 node that will be managed by this composer. This is usually a [Node` or [ForeignNode][] instance. + component_remaps : dict[str, str], optional + Any remaps you want to apply to all *components* loaded into this composer. + output : LogSink | Iterable[LogSink] | Iterable[str] | str, optional + Determines if and where this node's output should be directed. Common choices are `screen` to print to terminal, `log` to write to a common log file, `own_log` to write to a node-specific log file, and `none` to not write any output anywhere. See [configure_logger][utils.better_logging.configure_logger] for details. + + Raises + ------ + ValueError + If the composer mode is not recognized. + """ + super().__init__( + wrapped_node.package, + wrapped_node.executable, + wrapped_node.name, + wrapped_node.namespace, + output=output, + ) + + self._wrapped_node = wrapped_node + + pkg = self._wrapped_node.package + m = re.match(r"rcl(.+)_components", pkg) + if m: + self._language = m.group(1) + else: + self._language = None + + # Remaps are not useful for a composable node, but we can forward them to the components + self._component_remaps: dict[str, str] = component_remaps or {} + self._managed_components: dict[int, Component] = {} + + @property + def is_running(self) -> bool: + return self._wrapped_node.is_running + + @property + def is_lifecycle(self) -> bool: + """Composers are not lifecycle nodes.""" + return False + + @property + def language(self) -> str: + """The implementation language of this composer, usually `cpp` or `py`. Corresponds to this composer's package (e.g. *rclcpp_composition*), but will be `None` if it's a custom implementation.""" + self._language + + @property + def managed_components(self) -> list[Component]: + """The components that were explicitly loaded through this composer instance. This will not contain components that have been loaded via external service calls.""" + return list(self._managed_components.values()) + + def get_live_components(self) -> dict[int, str]: + """Use a service call to retrieve the components currently loaded into this composer and their IDs. + + Returns + ------- + dict[int, str] + A dict mapping component IDs to full node names. + """ + if not self._wrapped_node.is_running: + return [] + + from better_launch import BetterLaunch + from composition_interfaces.srv import ListNodes + + bl = BetterLaunch.instance() + res = bl.call_service( + f"{self.fullname}/_container/list_nodes", + ListNodes, + timeout=5.0, + ) + + return [(uid, name) for uid, name in zip(res.unique_ids, res.full_node_names)] + + def join(self, timeout: float = None) -> None: + self._wrapped_node.join(timeout) + + def start(self, service_timeout: float = 5.0) -> None: + """Start this node. Once this succeeds, [is_running][AbstractNode.is_running] will return True. + + Parameters + ---------- + service_timeout : float, optional + How long to wait for each composition service to appear (3 total). Wait forever if set negative. Don't check at all if None or 0. + """ + try: + self._wrapped_node.start() + except NotImplementedError: + pass + + if service_timeout not in (0.0, None): + # Check if all expected services are present + from better_launch import BetterLaunch + from composition_interfaces.srv import ListNodes, LoadNode, UnloadNode + + services = { + f"{self.fullname}/_container/list_nodes": ListNodes, + f"{self.fullname}/_container/load_node": LoadNode, + f"{self.fullname}/_container/unload_node": UnloadNode + } + + bl = BetterLaunch.instance() + for topic, srv_type in services.items(): + srv = bl.service_client(topic, srv_type, timeout=service_timeout) + srv.destroy() + + def shutdown(self, reason: str, signum: int = signal.SIGTERM, timeout: float = None) -> None: + """This will shutdown the composer node. Unloading any loaded components is left to the actual composer implementation. + + Parameters + ---------- + reason : str + A human-readable string describing why the composer and its components are being shutdown. + signum : int, optional + The signal that should be send to the composer. + timeout : float, optional + How long to wait for each component and the composer to shutdown before returning. Don't wait if timeout is 0.0. Wait forever if timeout is None. + + Raises + ------ + TimeoutError + If a timeout > 0 was set and any of the components or the composer did not shutdown before then. + """ + if not self._wrapped_node.is_running: + # Clear up internal states + for comp in self.managed_components: + comp._component_id = None + self.managed_components.clear() + return + + for comp in reversed(self.managed_components): + try: + comp.shutdown(reason, signum, timeout) + except Exception as e: + self.logger.warning(f"Failed to unload component {comp}: {e}") + + try: + self._wrapped_node.shutdown(reason, signum, timeout) + except NotImplementedError: + pass + + self.managed_components.clear() + + def load_component( + self, + component: Component, + use_intra_process_comms: bool = True, + **composer_extra_params: dict, + ) -> int: + """Load this component into its composer. + + Additional keyword arguments will be passed as ROS parameters to the component. If the component is not associated with this composer yet, a warning will be logged and its association will be updated. + + Note that since components are loaded via a service call that there are some additional restrictions on the types of `Component.params` and `composer_extra_params`. In particular, they must be compatible with the `ROS2 Parameter message type ][]_. + + Also note that new nodes created by a component are using the composer's remaps. See the `related issue `_ in rclcpp. + + Parameters + ---------- + component: Component + The component to load. + use_intra_process_comms : bool, optional + If True, the component will use intra process communication for exchanging messages with other components within the same composer. + + Returns + ------- + int + The ID the component got assigned by ROS. + + Raises + ------ + ValueError + If this composer is not running, if the component is already loaded, or if the parameters could not be serialized. + RuntimeError + If loading the component failed. + """ + if not self.is_running: + self.logger.error("Cannot load components into stopped composer") + return -1 + + if component.is_running: + self.logger.error("Cannot load an already running component") + return -1 + + if component.composer != self: + self.logger.warning( + f"Component {component.name} was created for a different Composer, updating reference" + ) + component._composer = self + + # Reference: https://github.com/ros2/launch_ros/blob/rolling/launch_ros/launch_ros/actions/load_composable_nodes.py + from composition_interfaces.srv import LoadNode + + req = LoadNode.Request() + req.package_name = component.package + req.plugin_name = component.plugin + req.node_name = component.name + req.node_namespace = component.namespace + req.parameters = [] + + try: + req.parameters.extend( + [ + # Parameter will initialize based on the value's type + # #58: components don't seem to handle qualifiers + Parameter(name=k.replace(":", "."), value=v).to_parameter_msg() + for k, v in component._flat_params(drop_qualifiers=True).items() + ] + ) + except Exception as e: + raise ValueError(f"Could not serialize component parameters: {e}") from e + + remaps = dict(self._component_remaps) + remaps.update(component.remaps) + req.remap_rules = [f"{src}:={dst}" for src, dst in remaps.items()] + + composer_params = {} + composer_params.update(composer_extra_params) + composer_params["use_intra_process_comms"] = use_intra_process_comms + try: + req.extra_arguments = [ + Parameter(name=k, value=v).to_parameter_msg() + for k, v in composer_params.items() + ] + except Exception as e: + raise ValueError(f"Could not serialize extra parameters: {e}") from e + + # Call the load_node service + from better_launch import BetterLaunch + + self.logger.info(f"Loading component {component.name}") + bl = BetterLaunch.instance() + + res = bl.call_service( + f"{self.fullname}/_container/load_node", + LoadNode, + req, + timeout=5.0, + ) + + if res.success: + if res.full_node_name: + namespace, name = res.full_node_name.rsplit("/", maxsplit=1) + component._namespace = namespace + component._name = name + + # Component.start() takes care of this, but the user can call load_component directly + cid = res.unique_id + component._component_id = cid + component._terminated_event.clear() + + self._managed_components[cid] = component + return cid + else: + self.logger.error( + f"Loading component {component} failed: {res.error_message}" + ) + raise RuntimeError(res.error_message) + + def unload_component(self, component: Component | int, timeout: float = None) -> None: + """Unload the specified component, essentially stopping its node. + + Note that an unload request will be issued even if the component reports it is not loaded. + + Parameters + ---------- + component : Component | int + The component or a component's ID to stop. + timeout : float, optional + How long to wait for the component to be unloaded before returning. Don't wait if timeout is 0.0. Wait forever if timeout is None. + + Returns + ------- + bool + True if unloading the component succeeded, False otherwise. + + Raises + ------ + ValueError + If this composer is not running. + KeyError + If the provided component has not been loaded into this node + """ + if not self.is_running: + raise ValueError("Cannot unload components from stopped composer") + + if isinstance(component, Component): + cid = component.component_id + else: + cid = component + + if cid in self._managed_components: + cname = self._managed_components[cid].name + else: + cname = self.get_live_components()[cid] + + from composition_interfaces.srv import UnloadNode + + req = UnloadNode.Request() + req.unique_id = cid + + self.logger.warning(f"Unloading component {cid} ({cname})") + + from better_launch import BetterLaunch + + bl = BetterLaunch.instance() + res = bl.call_service( + f"{self.fullname}/_container/unload_node", + UnloadNode, + req, + timeout=5.0, + ) + + if res.success: + # Usually Component.shutdown() takes care of this, but the user can call + # unload_component() directly + if isinstance(component, Component): + component._component_id = None + component._terminated_event.set() + + self._managed_components.pop(cid, None) + return True + + self.logger.error( + f"Unloading component {cid} ({cname}) failed: {res.error_message}" + ) + return False + + def _get_info_section_general(self): + info = super()._get_info_section_general() + components = "\n".join( + [f" - {c.plugin}" for c in self.managed_components] + ) + return ( + info + + f""" +\x1b[1mComponents\x1b[0m +{components} +""" + ) diff --git a/src/lib/better_launch/better_launch/elements/foreign_node.py b/src/lib/better_launch/better_launch/elements/foreign_node.py new file mode 100644 index 0000000000..e501c427ff --- /dev/null +++ b/src/lib/better_launch/better_launch/elements/foreign_node.py @@ -0,0 +1,599 @@ +from typing import Any, Iterable +import os +import time +import psutil +import signal +import logging +from fnmatch import fnmatch +import re +import json +from pathlib import Path +import threading +from xml.etree import ElementTree + +from ament_index_python.packages import get_packages_with_prefixes + +from better_launch.utils.better_logging import LogSink +from .abstract_node import AbstractNode +from .node import Node +from .live_params_mixin import LiveParamsMixin +from .lifecycle_manager import LifecycleStage + + +def find_ros2_node_processes() -> list[psutil.Process]: + """Finds processes that seem to be ROS2 nodes. + + Unfortunately, ROS2 doesn't provide any means of discovering node internals other than by looking at the process command line. Lucky for us, there are a couple of distinct command line arguments that are somewhat unique to ROS. These are: + - --ros-args for passing arguments + - __ns:= + - __node:= + - __name:= + + If any of these are present, the process will be added to the returned list. + + Returns + ------- + list[psutil.Process] + The processes that appear to be ROS2 nodes. + """ + # NOTE we won't be able to discover nodes started by ros2 run this way, but there's really + # nothing distinctive about those, e.g.: + # + # /usr/bin/python3 /opt/ros/humble/bin/ros2 run examples_rclpy_minimal_publisher publisher_local_function + # /usr/bin/python3 /opt/ros/humble/lib/examples_rclpy_minimal_publisher/publisher_local_function + ret = [] + + for p in psutil.process_iter(): + try: + cmd = p.cmdline() + if p.is_running() and ( + "--ros-args" in cmd + or "__ns:=" in cmd + or "__node:=" in cmd + or "__name:=" in cmd + ): + ret.append(p) + except psutil.ZombieProcess: + pass + + return ret + + +def find_process_for_node(namespace: str, name: str) -> list[psutil.Process]: + """Find processes that look like ROS2 nodes which have been passed the specified namespace and name. + + Parameters + ---------- + namespace : str + The namespace to look for. + name : str + The node name to look for. + + Returns + ------- + list[psutil.Process] + A list processes that match the above criteria. + """ + r_pkg = re.compile(rf"__ns:={namespace}") + r_name = re.compile(rf"__(?:node|name):={name}") + + candidates = [] + + for p in psutil.process_iter(): + pkg_match = False + name_match = False + + try: + cmd = p.cmdline() + for arg in cmd: + if r_pkg.match(arg): + pkg_match = True + elif r_name.match(arg): + name_match = True + + if pkg_match and name_match: + candidates.append(p) + break + except psutil.ZombieProcess: + pass + + return candidates + + +def get_package_for_path(path: str) -> tuple[str, str]: + """Find the ROS2 package associated with the specified path. + + This will first check the currently registered packages (which are stored in `$AMENT_PREFIX_PATH`). If none of these match it will search through the path starting from the end for a valid `package.xml` file to get the package name. + + Parameters + ---------- + path : str + An absolute path to find the package for. + + Returns + ------- + tuple[str, str] + The package name and path to the package, or (None, None) if the package could not be determined. + """ + path: Path = Path(path).resolve() + ros_packages = get_packages_with_prefixes() + + for pkg, pkg_path in ros_packages.items(): + # Try to find the package in the currently registered packages + if path.name.startswith(pkg_path): + return pkg, pkg_path + else: + # Not a package currently sourced, look for a package.xml somewhere on the path. This search + # is somewhat expensive, but we expect it to be rare since usually packages should already + # be sourced 99.9% of the time + while True: + # Launch files are usually inside a subfolder of share//launch/, and the + # package.xml should be in share/ + package_xml = (path / "package.xml").resolve() + + if package_xml.is_file(): + # Unfortunately the package name can be different from the package's folder, so + # we get it from the package.xml instead + tree = ElementTree.parse(package_xml) + root = tree.getroot() + if root.tag == "package": + name_tag = root.find("name") + if name_tag is not None: + # Found it! + return name_tag.text, str(path) + + # Not an actual package file, go up one level + path = path.parent + + # We can probably stop if are at the file system root :) + if path == path.parent: + break + + return None, None + + +def parse_process_args( + process: psutil.Process, node: AbstractNode = None +) -> tuple[str, str, dict[str, str], dict[str, str], list[str], list[str]]: + """Parse ROS2 command line arguments and return the user-specified parts. + + In particular, this will return the passed ROS2 params, remaps and additional command line arguments. Special ROS2 arguments like `--ros-args`, `--remap`, etc. will not be included. A node may be passed in order to resolve `nodename:key:=value` style args and load parameter files from `--params-file`. + + Parameters + ---------- + process : psutil.Process + The process to get the cmd args from. + node : AbstractNode, optional + A node to use when resolving additional details. + + Returns + ------- + tuple[dict[str, str], dict[str, str], list[str]] + The node's (namespace, name, remaps, params, param_files, cmd_args). + """ + from better_launch import BetterLaunch + + node_name = "" + namespace = "" + remaps = {} + params = {} + param_files = [] + additional_args = [] + is_ros_args = False + skip = 1 + + cmd_args = process.cmdline() + bl = BetterLaunch.instance() + + for i, arg in enumerate(cmd_args): + if skip > 0: + # Skip the executable and args we already parsed + skip -= 1 + continue + + if arg == "--ros-args": + is_ros_args = True + continue + + if is_ros_args: + if arg in ["-r", "--remap"]: + skip = 1 + key, val = cmd_args[i + 1].split(":=") + if key == "__ns": + namespace = val + elif key in ["__name", "__node"]: + node_name = val + elif ":" in key: + # ROS2 supports a pattern where the key is preceded by the + # node's name to make node-specific remaps for e.g. components + qualifier, key = key.split(":", maxsplit=1) + # TODO if we have the node we don't need the node name...? + if node: + if qualifier != node.name and not fnmatch(node.fullname, qualifier): + continue + + # NOTE: an especially unhinged developer could write a node process creating + # multiple nodes and then start them like + # + # -r __name:=X -r node1:__name:=Y -r node2:__name:=Z ... + # + # In this case we will get multiple matches here unless we have a node name to + # look for. Even if there is an unqualified __ns or __name we cannot tell if + # it is overridden by a more specific remap rule unless we know the original + # node name. + if key == "__ns": + if namespace and not node: + logging.getLogger().warning( + f"Node process {process.pid} has ambivalent namespace arguments: {cmd_args}" + ) + namespace = val + elif key in ["__name", "__node"]: + if node_name and not node: + logging.getLogger().warning( + f"Node process {process.pid} has ambivalent node name arguments: {cmd_args}" + ) + node_name = val + else: + remaps[key] = val + + elif arg in ["-p", "--param"]: + skip = 1 + key, val = cmd_args[i + 1].split(":=") + params[key] = val + + elif arg == "--params-file": + skip = 1 + param_files.append(cmd_args[i + 1]) + + else: + # No special handling + bl.logger.warning("parse_process_args: unhandled ros-arg %s", arg) + else: + # Nothing we handle, assume it's a regular command line arg + additional_args.append(arg) + + return namespace, node_name, remaps, params, param_files, additional_args + + +def find_foreign_nodes() -> list[AbstractNode]: + """Searches the running processes for ROS2 nodes that have not been started by this *better_launch* process and wraps them in ForeignNode instances. + + Returns + ------- + list[AbstractNode] + A list of discovered foreign ROS2 nodes. + """ + from better_launch import BetterLaunch + + bl = BetterLaunch.instance() + + # bl.query_node would iterate over all nodes every time + my_nodes = { + n.fullname + for n in bl.get_nodes( + include_components=True, include_launch_service=False, include_foreign=False + ) + } + + foreign = [] + for p in find_ros2_node_processes(): + try: + foreign.append(ForeignNode.wrap_process(p)) + except Exception as e: + bl.logger.debug(f"Could not create foreign node: {e}", exc_info=True) + + return filter(lambda n: n.fullname not in my_nodes, foreign) + + +class ForeignNode(AbstractNode, LiveParamsMixin): + @classmethod + def wrap_process(cls, process: psutil.Process) -> "ForeignNode": + """Collect information like namespace, package, executable and so on from a process and wrap it in a ForeignNode instance. + + Note that there is no way to verify whether the process actually belongs to a ROS node. Consider using e.g. [find_ros2_node_processes][] to that effect. + + Parameters + ---------- + process : psutil.Process + The process to wrap. + + Returns + ------- + ForeignNode + A node object carrying the extracted node information. + """ + from better_launch import BetterLaunch + + bl = BetterLaunch.instance() + + namespace, name, params, param_files, remaps, additional_args = parse_process_args(process) + exec_dir = os.path.dirname(process.cmdline()[0]) + package, _ = get_package_for_path(exec_dir) + + if not namespace: + bl.logger.warning("Process did not specify a node namespace") + + if not name: + bl.logger.warning("Process did not specify a node name") + + return ForeignNode( + process, + package, + name, + namespace, + remaps=remaps, + params=params, + param_files=param_files, + cmd_args=additional_args, + ) + + def __init__( + self, + process: psutil.Process, + package: str, + name: str, + namespace: str, + *, + remaps: dict[str, str] = None, + params: str | dict[str, Any] = None, + param_files: list[str] = None, + cmd_args: list[str] = None, + output: LogSink | Iterable[LogSink] | Iterable[str] | str = LogSink.SCREEN, + ): + """This class is used for representing nodes not managed by this better_launch process. + + Although ROS2 has no mechanism to retrieve e.g. the package name from a running node, most information can be retrieved from the process object itself. + + In general, you should probably use [ForeignNode.wrap_process][] instead of instantiating this class directly. + + Parameters + ---------- + process : psutil.Process + A reference to the running node's process. + package : str + The package providing the node. + name : str + This node's name. If it is a ROS node it should be how the node registers with ROS. + namespace : str + The node's namespace. Must be absolute, i.e. start with a '/'. + remaps : dict[str, str], optional + Tells the node to replace any topics it wants to interact with according to the provided dict. + params : str | dict[str, Any], optional + Any arguments you want to provide to the node. These are the args you would typically have to declare in your launch file. A string will be interpreted as a path to a yaml file which will be lazy loaded using [BetterLaunch.load_params][]. + param_files : list[str], optional + Paths to parameter files that will be passed to the node as is. If both param_files and params are present, param_files will be passed first (same order), followed by the params. + cmd_args : list[str], optional + Additional command line arguments to pass to the node. + output : LogSink | Iterable[LogSink] | Iterable[str] | str, optional + Determines if and where this node's output should be directed. *Note* however that this only applies to output generated by THIS process, not the node's output. To properly capture the node output you must call [ForeignNode.takeover][] first. + """ + super().__init__( + package, + process.cmdline()[0], + name, + namespace, + remaps=remaps, + params=params, + param_files=param_files, + output=output, + ) + + self._process = process + self._cmd_args = cmd_args + + # Watch the process and notify user when it terminates + self._watch_process() + + @property + def pid(self) -> int: + """The process ID of the node process. Will be -1 if the process is not running.""" + if not self.is_running: + return -1 + return self._process.pid + + @property + def cmd_args(self) -> list[str]: + """Additional arguments passed to the node process.""" + return self._cmd_args + + @property + def is_running(self) -> bool: + return self._process and self._process.is_running() + + def join(self, timeout: float = None) -> int: + """Wait for the underlying process to terminate and return its exit code. Returns immediately if the process is not running. + + Parameters + ---------- + timeout : float, optional + How long to wait for the process to finish. Wait forever if None. + + Returns + ------- + int + The exit code of the process, or None if it is already terminated. + + Raises + ------ + TimeoutError + If a timeout was specified and the process is still running by the time the timeout expires. + """ + proc = self._process + if proc: + try: + # Seems to work here despite setpgrp? + return proc.wait(timeout) + except psutil.TimeoutExpired as e: + raise TimeoutError from e + + def start(self) -> None: + """Usually a foreign node will already be running upon construction. However, this method can be used when restarting the process after it has terminated. + + See also [takeover][]. + """ + from better_launch import BetterLaunch + + bl = BetterLaunch.instance() + if bl.is_shutdown: + self.logger.warning( + f"Node {self} will not be started as the launcher has already shut down" + ) + return + + if self.is_running: + self.logger.warning(f"Node {self} is already started") + return + + final_cmd = [self.executable] + self.cmd_args + ["--ros-args"] + + # Special args and remaps + # launch_ros/actions/node.py:206 + for src, dst in self._ros_args().items(): + # launch_ros/actions/node.py:481 + final_cmd.extend(["-r", f"{src}:={dst}"]) + + # Pass param files first + # TODO this changes the order of arguments in which the original process was invoked... + for path in self.param_files: + final_cmd.extend(["--param-file", path]) + + # Attach node parameters + for key, value in self._flat_params().items(): + # Make sure the values are parseable for ROS + final_cmd.extend(["-p", f"{key}:={json.dumps(value)}"]) + + self.logger.info(f"Starting process '{' '.join(final_cmd)}'") + + # Start the node process + # NOTE it is very difficult if not impossible to capture a process' output if we did not + # set it up ourselves. The clean way is to terminate the process and restart it from this + # process. See the takeover function below which does precisely that. + self._process = psutil.Popen( + final_cmd, + cwd=None, + shell=False, + text=True, + preexec_fn=os.setpgrp, # start in separate process group + ) + + # Watch the process and notify user when it terminates + self._watch_process() + + def _watch_process(self) -> None: + # TODO Capture process output! + # To capture output we can use `strace -p1234 -s9999 -e write` where 1234 is the process + # pid. However, this requires sudo rights unless we got the "cap_sys_ptrace+ep" capability + # granted. This can only be granted to binary programs, not to python scripts, so we'd have + # to create a small C++ binary (bl?). Alternatively we need to request sudo permissions, + # but the input line might get swepped away by other node logs... + + def wait(): + ret = self.join() + + if ret is None: + self.logger.warning("Process has already finished") + elif ret == 0: + self.logger.warning("Process has finished cleanly") + else: + self.logger.critical(f"Process has died with exit code {ret}") + + # TODO this is probably a great use case for asyncio + threading.Thread(target=wait, daemon=True).start() + + def shutdown( + self, reason: str, signum: int = signal.SIGTERM, timeout: float = 0.0 + ) -> None: + if not self.is_running: + return + + signame = signal.Signals(signum).name + + if signum == signal.SIGTERM and self._lifecycle_manager: + try: + self._lifecycle_manager.transition(LifecycleStage.FINALIZED) + except Exception as e: + self.logger.warning(f"Lifecycle transition to FINALIZED failed: {e}") + + self.logger.warning( + f"Forwarding shutdown signal to foreign process: {reason} ({signame})" + ) + self._process.send_signal(signum) + + if timeout == 0.0: + return + + try: + self._process.wait(timeout) + except psutil.TimeoutExpired: + raise TimeoutError("Node did not shutdown within the specified timeout") + + def takeover(self, kill_after: float = 0, **node_args) -> Node: + """Replaces a foreign node with a node belonging to this better_launch process. This allows + to e.g. capture the node's output and control a few additional runtime parameters. Any + interactions with this foreign node instance after this function returns are undefined + behavior. + + **NOTE:** Currently the only way to takeover a node is to stop the original process, then + recreate and restart the node with the same arguments as the original node. + + Parameters + ---------- + kill_after: float, optional + Kill the node process if it takes longer than this many seconds to shutdown. Disabled when <= 0. + node_args : dict[str, Any], optional + Additional arguments to pass to the node. See [Node][] for additional details. + + Returns + ------- + Node + The new node instance that should replace this foreign node. + """ + from better_launch import BetterLaunch + + start = time.time() + self.shutdown("Taking over node") + + while True: + if self.is_running: + break + + time.sleep(0.1) + now = time.time() + + if kill_after > 0 and now - start > kill_after: + self._process.kill() + break + + self.logger.info("Old process has terminated, restarting node") + node = Node( + self.package, + self.executable, + self.name, + self.namespace, + remaps=self.remaps, + params=self.params, + cmd_args=self.cmd_args, + **node_args, + ) + + bl = BetterLaunch.instance() + g = bl.find_group_for_namespace(self.namespace, True) + g.add_node(node) + + node.start() + + # This ForeignNode instance should not be used anymore + self._process = None + + return node + + def _get_info_section_general(self): + info = super()._get_info_section_general() + return ( + info + + f""" +\x1b[1mProcess\x1b[0m + PID: {self.pid} + Cmd Args: {self.cmd_args} +""" + ) diff --git a/src/lib/better_launch/better_launch/elements/group.py b/src/lib/better_launch/better_launch/elements/group.py new file mode 100644 index 0000000000..3a3032d957 --- /dev/null +++ b/src/lib/better_launch/better_launch/elements/group.py @@ -0,0 +1,91 @@ +from .node import Node +from .abstract_node import AbstractNode + + +class Group: + def __init__(self, parent: "Group", namespace: str, use_sim_time: bool): + """Groups are used in better_launch to manage node namespaces and common remaps. Beyond that they don't have any meaning for ROS, and there is usually no reason to interact with them directly. + + Parameters + ---------- + parent : Group + This group's parent group. + namespace : str + The namespace fragment this group represents. + use_sim_time : bool + Whether nodes inside this group should use simulated time. + """ + self.parent = parent + self.namespace = namespace + self.use_sim_time = use_sim_time + self.children: dict[str, Group] = {} + self.nodes: list[AbstractNode] = [] + + self._root_chain = self._get_chain_from_root() + + def _get_chain_from_root(self, include_root: bool = False) -> list["Group"]: + # The launcher doesn't keep the group tree, but we can rebuild at least our own branch + chain = [] + g = self + while g is not None: + chain.append(g) + g = g.parent + + if include_root and g: + chain.append(g) + + return list(reversed(chain)) + + def assemble_namespace(self) -> str: + """Return the full namespace string this group represents. + + Returns + ------- + str + This group's namespace path from the root group. + """ + ns = "" + + root = self._root_chain[0].parent + if root: + ns = root.namespace.strip("/") + + for g in self._root_chain: + ns += "/" + g.namespace.strip("/") + + while "//" in ns: + ns = ns.replace("//", "/") + + return ns + + def add_child(self, child: "Group") -> None: + """Add a child group to this group. + + Parameters + ---------- + child : Group + The group to add. + """ + ns = child.namespace + if ns in self.children: + raise ValueError(f"Group {self.namespace} already contains a child group named '{ns}'") + + self.children[ns] = child + + def add_node(self, node: Node) -> None: + """Add a node to this group. + + This group will not do any magic to enforce its namespace or remaps onto the node. Calling this with a node that has been added before will do nothing. + + Parameters + ---------- + node : Node + The node to add. + """ + if node in self.nodes: + return + + self.nodes.append(node) + + def __repr__(self) -> str: + return self.assemble_namespace() diff --git a/src/lib/better_launch/better_launch/elements/lifecycle_manager.py b/src/lib/better_launch/better_launch/elements/lifecycle_manager.py new file mode 100644 index 0000000000..6d041a1fd4 --- /dev/null +++ b/src/lib/better_launch/better_launch/elements/lifecycle_manager.py @@ -0,0 +1,247 @@ +from enum import IntEnum +from collections import deque +import time + +from lifecycle_msgs.msg import TransitionEvent, State, Transition +from lifecycle_msgs.srv import ChangeState as ChangeLifecycleState + + +class LifecycleStage(IntEnum): + """Represents the main stages of nodes that support lifecycle management. + """ + #There is usually little reason to be exposed to all the intermediate stages and transitions ROS defines. + PRISTINE = 0 + CONFIGURED = 1 + ACTIVE = 2 + FINALIZED = 3 + + +_stage_to_ros_state = { + LifecycleStage.PRISTINE: State.PRIMARY_STATE_UNCONFIGURED, + LifecycleStage.CONFIGURED: State.PRIMARY_STATE_INACTIVE, + LifecycleStage.ACTIVE: State.PRIMARY_STATE_ACTIVE, + LifecycleStage.FINALIZED: State.PRIMARY_STATE_FINALIZED, +} + + +# See https://design.ros2.org/articles/node_lifecycle.html +_transition_map = { + State.PRIMARY_STATE_UNCONFIGURED: [ + (Transition.TRANSITION_CONFIGURE, State.PRIMARY_STATE_INACTIVE), + (Transition.TRANSITION_UNCONFIGURED_SHUTDOWN, State.PRIMARY_STATE_FINALIZED), + ], + State.PRIMARY_STATE_INACTIVE: [ + (Transition.TRANSITION_INACTIVE_SHUTDOWN, State.PRIMARY_STATE_FINALIZED), + (Transition.TRANSITION_CLEANUP, State.PRIMARY_STATE_UNCONFIGURED), + (Transition.TRANSITION_ACTIVATE, State.PRIMARY_STATE_ACTIVE), + ], + State.PRIMARY_STATE_ACTIVE: [ + (Transition.TRANSITION_DEACTIVATE, State.PRIMARY_STATE_INACTIVE), + (Transition.TRANSITION_ACTIVE_SHUTDOWN, State.PRIMARY_STATE_FINALIZED), + ], + State.PRIMARY_STATE_FINALIZED: [ + (Transition.TRANSITION_DESTROY, State.PRIMARY_STATE_UNKNOWN) + ], +} + + +# Forward declaration to avoid cyclic imports +class AbstractNode: + ... + + +class LifecycleManager: + @classmethod + def is_lifecycle(cls, node: AbstractNode, timeout: float = None) -> bool: + """Checks whether a node supports lifecycle management. + + For a node to support lifecycle management, it must be running, be registered with ROS and offer the ROS lifecycle management services. This method **only** checks whether one of the key services is present. + + If a timeout is specified, the check will be repeated until it succeeds or the specified amount of time has passed. This is to ensure that a freshly started node had enough time to create its topics, especially on slower devices. See [AbstractNode.is_lifecycle_node][better_launch.elements.abstract_node.AbstractNode.is_lifecycle_node] for additional information. + + Parameters + ---------- + node : AbstractNode + The node object to check for lifecycle support. + timeout : float + How long to wait at most for the lifecycle services to appear. Wait forever if None. + + Returns + ------- + bool + True if the node supports lifecycle management, False otherwise. + """ + now = time.time() + while True: + # Check if the node provides one of the key lifecycle services + services = node.get_published_services() + for srv_name, srv_types in services.items(): + if ( + srv_name == f"{node.fullname}/get_available_transitions" + and "lifecycle_msgs/srv/GetAvailableTransitions" in srv_types + ): + return True + + if timeout is not None and time.time() > now + timeout: + break + + time.sleep(0.1) + + return False + + @classmethod + def find_transition_path(cls, start_ros_state: int, goal_ros_state: int) -> list[int]: + """Finds a sequence of transitions that will bring a node from an initial lifecycle state (the ROS state, not our LifecycleStage enum) to a target lifecycle state. + + This is fairly low level and probably never needed. + + Parameters + ---------- + start_ros_state : int + The initial ROS lifecycle state. + goal_ros_state : int + The final ROS lifecycle state. + + Returns + ------- + list[int] + A sequence of ROS lifecycle transitions that form a path from the start state to the goal state. + """ + if start_ros_state == goal_ros_state: + return [] + + # Queue for BFS: (current_state, path_to_state) + queue = deque([(start_ros_state, [])]) + visited = set() + + while queue: + current_state, path = queue.popleft() + + if current_state == goal_ros_state: + return path + + if current_state in visited: + continue + + visited.add(current_state) + + # Explore transitions from the current state + for transition, next_state in _transition_map.get(current_state, []): + if next_state not in visited: + queue.append((next_state, path + [transition])) + + # No path found + return [] + + def __init__(self, node: AbstractNode): + """Offers additional functionality to manage a node's lifecycle. Verification whether a node supports lifecycle management should happen **before** this is instantiated. + + Parameters + ---------- + node : AbstractNode + The node who's lifecycle should be managed. + + Raises + ------ + RuntimeError + If the transition service failed to connect. + """ + self._current_stage = LifecycleStage.PRISTINE + self._current_ros_state: int = State.PRIMARY_STATE_UNCONFIGURED + self._node = node + + from better_launch import BetterLaunch + + launcher = BetterLaunch.instance() + self._state_sub = launcher.shared_node.create_subscription( + TransitionEvent, + f"{self._node.fullname}/transition_event", + self._on_transition_event, + 10, + ) + + self._transition_client = launcher.shared_node.create_client( + ChangeLifecycleState, f"{self._node.fullname}/change_state" + ) + if not self._transition_client.wait_for_service(5.0): + raise RuntimeError("Could not connect to lifecycle transition service") + + @property + def current_stage(self) -> LifecycleStage: + """The node's current (that is, last known) lifecycle stage. + """ + return self._current_stage + + @property + def ros_state(self) -> int: + """The node's current (that is, last known) ROS lifecycle state ID. + """ + return self._current_ros_state + + def transition(self, target_stage: LifecycleStage | str) -> bool: + """Transition the managed node into the target lifecycle stage. Does nothing if the node is already in the desired stage. + + Note that you **don't** have to do step-by-step transitions - simply specify the stage you want the node to end up in and it will go through all the intermediate steps (assuming a path exists). + + Parameters + ---------- + target_stage : LifecycleStage | str + The lifecycle stage you want the node to end up in. + + Returns + ------- + bool + True if the transition sequence succeeded, False if one of the steps failed. + + Raises + ------ + ValueError + If no path to the target stage could be found. + """ + if isinstance(target_stage, str): + target_stage = LifecycleStage[target_stage.upper()] + + if target_stage == self.current_stage: + return True + + # Figure out if and how we can get from our current state to the target state + target_ros_state = _stage_to_ros_state[target_stage] + transition_path = LifecycleManager.find_transition_path( + self._current_ros_state, target_ros_state + ) + + if not transition_path: + raise ValueError( + f"Could not find a valid transition sequence for {self._current_stage} -> {target_stage}" + ) + + for transition in transition_path: + if not self._do_transition(transition): + self._node.logger.error( + f"Lifecycle transition {transition} for {self._node.name} towards stage {target_stage} failed" + ) + return False + + # TODO we may not have received the transition update yet + return True + + def _on_transition_event(self, evt: TransitionEvent) -> None: + """Update the current state ID and stage. + """ + self._current_ros_state = evt.goal_state.id + for key, val in _stage_to_ros_state.items(): + if val == evt.goal_state.id: + self._current_stage = key + break + else: + self._current_stage = -1 + + def _do_transition(self, transition_id: int) -> bool: + """Issue a transition service request. + """ + req = ChangeLifecycleState.Request() + req.transition.id = transition_id + + # TODO should make this async, this can take a few seconds + res = self._transition_client.call(req) + return res.success diff --git a/src/lib/better_launch/better_launch/elements/live_params_mixin.py b/src/lib/better_launch/better_launch/elements/live_params_mixin.py new file mode 100644 index 0000000000..affba1384a --- /dev/null +++ b/src/lib/better_launch/better_launch/elements/live_params_mixin.py @@ -0,0 +1,212 @@ +from typing import Any + +from rclpy.parameter import Parameter + +try: + # Jazzy + from rclpy.parameter import parameter_value_to_python +except ImportError: + # Humble + from ros2param.api import get_value as get_value_humble + + def parameter_value_to_python(p: Parameter): + # keyword args only + return get_value_humble(parameter_value=p) + + +class LiveParamsMixin: + """Mixin class to add interactions with ROS parameters for running nodes. This class must be mixed in with an object providing a `fullname` member.""" + + def __init__(self): + super().__init__() + + # NOTE: we should avoid storing services as they rely on the ros_adapter staying alive, as + # it can be terminated for performance reasons. Better to use call_service instead. + self._list_params_req_type = None + self._get_params_req_type = None + self._set_params_req_type = None + self._set_params_atomic_req_type = None + + def list_live_params(self, *, timeout: float = 5.0) -> list[str]: + """List the names of the ROS parameters this node has registered. + + Note that this is different from the `params` used to start the node. This method will directly query the ROS node for its parameters, while `params` is used to pass arguments on startup. + + Parameters + ---------- + timeout : float, optional + How long to wait for the service to connect. + + Returns + ------- + list[str] + A list of this node's ROS parameters. + + Raises + ------ + TimeoutError + If the service fails to connect. + """ + from better_launch import BetterLaunch + + bl = BetterLaunch.instance() + + if not self._list_params_req_type: + self._list_params_req_type = bl.get_ros_message_type( + "rcl_interfaces/srv/ListParameters" + ) + + res = bl.call_service( + f"{self.fullname}/list_parameters", + self._list_params_req_type, + timeout=timeout, + ) + + return res.result.names + + def get_live_params(self, *params: str, timeout: float = 5.0) -> dict[str, Any]: + """Retrieves the values for the specified ROS parameters of this node. If no parameters are provided, all parameters will be listed. + + Note that this is different from the `params` used to start the node. This method will directly query the ROS node for its parameters, while `params` is used to pass arguments on startup. + + Parameters + ---------- + *params : str + ROS parameter names to retrieve. + timeout : float, optional + How long to wait for the service to connect. + + Returns + ------- + Any + The names and values of the ROS parameters of this node as specified. + + Raises + ------ + TimeoutError + If the service fails to connect. + """ + from better_launch import BetterLaunch + + bl = BetterLaunch.instance() + + if not self._get_params_req_type: + self._get_params_req_type = bl.get_ros_message_type( + "rcl_interfaces/srv/GetParameters" + ) + self._get_params_srv = bl.service_client( + f"{self.fullname}/get_parameters", + "rcl_interfaces/srv/GetParameters", + timeout=timeout, + ) + + if not params: + params = self.list_live_params(timeout=timeout) + + res = bl.call_service( + f"{self.fullname}/get_parameters", + self._get_params_req_type, + request_args={"names": params}, + timeout=timeout, + ) + + return { + name: parameter_value_to_python(pval) + for name, pval in zip(params, res.values) + } + + def set_live_params( + self, params: dict[str, Any], *, timeout: float = 5.0 + ) -> dict[str, bool]: + """Sets the specified ROS parameters on this node. + + Note that this is different from the `params` used to start the node. This method will directly query the ROS node for its parameters, while `params` is used to pass arguments on startup. + + Parameters + ---------- + params : dict[str, Any] + The parameters to update. + timeout : float, optional + How long to wait for the service to connect. + + Returns + ------- + dict[str, bool]: + A dict showing which parameter updates succeeded. + + Raises + ------ + TimeoutError + If the service fails to connect. + """ + from better_launch import BetterLaunch + + bl = BetterLaunch.instance() + + if not self._set_params_req_type: + self._set_params_req_type = bl.get_ros_message_type( + "rcl_interfaces/srv/SetParameters" + ) + + res = bl.call_service( + f"{self.fullname}/set_parameters", + self._set_params_req_type, + request_args={ + "parameters": [ + Parameter(key, value=val).to_parameter_msg() + for key, val in params.items() + ] + }, + timeout=timeout, + ) + + return { + param: item.successful for param, item in zip(params.keys(), res.results) + } + + def set_live_params_atomic( + self, params: dict[str, Any], *, timeout: float = 5.0 + ) -> bool: + """Sets the specified ROS parameters on this node. No updates will be performed if any of the operations fail. + + Note that this is different from the `params` used to start the node. This method will directly query the ROS node for its parameters, while `params` is used to pass arguments on startup. + + Parameters + ---------- + params : dict[str, Any] + The parameters to update. + timeout : float, optional + How long to wait for the service to connect. + + Returns + ------- + bool: + True if all updates succeeded, False otherwise. + + Raises + ------ + TimeoutError + If the service fails to connect. + """ + from better_launch import BetterLaunch + + bl = BetterLaunch.instance() + + if not self._set_params_atomic_req_type: + self._set_params_atomic_req_type = bl.get_ros_message_type( + "rcl_interfaces/srv/SetParametersAtomically" + ) + + res = bl.call_service( + f"{self.fullname}/set_parameters_atomically", + self._set_params_atomic_req_type, + request_args={ + "parameters": [ + Parameter(key, value=val).to_parameter_msg() + for key, val in params.items() + ] + }, + timeout=timeout, + ) + + return res.successful diff --git a/src/lib/better_launch/better_launch/elements/node.py b/src/lib/better_launch/better_launch/elements/node.py new file mode 100644 index 0000000000..0b7e301296 --- /dev/null +++ b/src/lib/better_launch/better_launch/elements/node.py @@ -0,0 +1,547 @@ +from typing import Any, Callable, Iterable +import os +import platform +import signal +import traceback +import time +import re +import logging +import threading +import subprocess +import queue +from pprint import pformat +import json +import shlex + +from better_launch.utils.better_logging import LogSink, ROSLOG_PATTERN_BL +from .abstract_node import AbstractNode +from .live_params_mixin import LiveParamsMixin +from .lifecycle_manager import LifecycleStage + + +class Node(AbstractNode, LiveParamsMixin): + def __init__( + self, + package: str, + executable: str, + name: str, + namespace: str, + *, + remaps: dict[str, str] = None, + params: str | dict[str, Any] = None, + param_files: str | list[str] = None, + use_sim_time: bool = False, + drop_param_qualifiers: bool = False, + cmd_args: str | list[str] = None, + prefix_args: str | list[str] = None, + env: dict[str, str] = None, + isolate_env: bool = False, + log_level: int = logging.INFO, + output: LogSink | Iterable[LogSink] | Iterable[str] | str = LogSink.SCREEN, + on_exit: Callable = None, + max_respawns: int = 0, + respawn_delay: float = 0.0, + use_shell: bool = False, + raw: bool = False, + remap_qualifier: str = None, + qualify_all_remaps: bool = False, + ): + """An object used for starting a ROS node and capturing its output. + + Parameters + ---------- + package : str + The package providing the node. + executable : str + The executable that should be run. + name : str, optional + The name you want the node to be known as. + namespace : str + The node's namespace. Must be absolute, i.e. start with a '/'. + remaps : dict[str, str], optional + Tells the node to replace any topics it wants to interact with according to the provided dict. + params : str | dict[str, Any], optional + Any arguments you want to provide to the node. These are the args you would typically have to declare in your launch file. A string will be interpreted as a path to a yaml file which will be lazy loaded using [BetterLaunch.load_params][]. + param_files : str | list[str], optional + Paths to parameter files that will be passed to the node as is. If both param_files and params are present, param_files will be passed first (same order), followed by the params. + drop_param_qualifiers : bool, optional + If True, any namespace/node qualifiers in the passed params are ignored. + cmd_args : str | list[str], optional + Additional command line arguments to pass to the node. If a string is passed it will be split using shlex. + prefix_args : str | list[str], optional + Arguments to prepend to the resolved run command, e.g. for executing the node through gdb. If a string is passed it will be split using shlex. + env : dict[str, str], optional + Additional environment variables to set for the node's process. The node process will merge these with the environment variables of the better_launch host process unless `isolate_env` is True. + isolate_env : bool, optional + If True, the node process' env will not be inherited from the parent process and only those passed via `env` will be used. Be aware that this can result in many common things to not work anymore since e.g. keys like *PATH* will be missing. + log_level : int, optional + The minimum severity a logged message from this node must have in order to be published. This will be added to the cmd_args unless it is None. + output : LogSink | Iterable[LogSink] | Iterable[str] | str, optional + Determines if and where this node's output should be directed. Common choices are `screen` to print to terminal, `log` to write to a common log file, `own_log` to write to a node-specific log file, and `none` to not write any output anywhere. See [configure_logger][utils.better_logging.configure_logger] for details. + on_exit : Callable, optional + A function to call when the node's process terminates (after any possible respawns). + max_respawns : int, optional + How often to restart the node process if it terminates. + respawn_delay : float, optional + How long to wait before restarting the node process after it terminates. + use_shell : bool, optional + If True, invoke the node executable via the system shell. While this gives access to the shell's builtins, this has the downside of running the node inside a "mystery program" which is platform and user dependent. Generally not advised. + raw : bool, optional + If True, don't treat the executable as a ROS2 node and avoid passing it any command line arguments except those specified. + remap_qualifier : str, optional + Additional qualifier that will precede the node's `__ns` and `__name` remap rules. Should be the original name of the node (i.e. whatever its default name is) and can be qualified with a namespace. Useful to prevent multiple nodes with the same name when a process can have more than one node (e.g. `controller_manager`). See `this ROS2 design doc int: + """The process ID of the node process. Will be -1 if the process is not running.""" + if not self.is_running: + return -1 + return self._process.pid + + @property + def is_running(self) -> bool: + return self._process is not None and self._process.poll() is None + + def join(self, timeout: float = None) -> int: + """Wait for the underlying process to terminate and return its exit code. Returns immediately if the process is not running. + + Parameters + ---------- + timeout : float, optional + How long to wait for the process to finish. Wait forever if None. + + Returns + ------- + int + The exit code of the process, or None if it is already terminated. + + Raises + ------ + TimeoutError + If a timeout was specified and the process is still running by the time the timeout expires. + """ + proc = self._process + if proc: + try: + # Seems to work here despite setpgrp? + return proc.wait(timeout) + except subprocess.TimeoutExpired as e: + raise TimeoutError from e + + def start(self) -> None: + from better_launch import BetterLaunch + + launcher = BetterLaunch.instance() + if launcher.is_shutdown: + self.logger.warning( + f"Node {self} will not be started as the launcher has already shut down" + ) + return + + if self.is_running: + self.logger.warning(f"Node {self} is already started") + return + + try: + cmd = launcher.find(f"{self.package}/lib", self.executable) + + final_cmd = self.prefix_args + [cmd] + if self.cmd_args: + final_cmd.extend(self.cmd_args) + + if not self.raw: + final_cmd += ["--ros-args"] + + if self.node_log_level is not None: + final_cmd += ["--log-level", self.node_log_level] + + # Special args and remaps + # https://github.com/ros2/launch_ros/blob/rolling/launch_ros/launch_ros/actions/node.py + + # Qualifier to create node-specific remaps + qualifier = "" + if self.remap_qualifier: + qualifier = self.remap_qualifier + if not qualifier.endswith(":"): + qualifier += ":" + + # Why do I hear mad hatter music??? + # See https://docs.ros.org/en/jazzy/How-To-Guides/Node-arguments.html + remaps = {} + if self.namespace: + remaps["__ns"] = self.namespace + if self.name: + remaps["__node"] = self.name + remaps.update(self.remaps) + + for src, dst in remaps.items(): + if qualifier: + if src in ("__ns", "__node", "__name"): + src = qualifier + src + elif self.qualify_all_remaps and ":" not in src: + src = qualifier + src + + final_cmd.extend(["-r", f"{src}:={dst}"]) + + # Pass param files first + for path in self.param_files: + final_cmd.extend(["--params-file", path]) + + # Attach node parameters + + # TODO I could not find a way to pass a qualified param to a namespaced node yet. + # See https://github.com/ros2/rcl/issues/1306 + drop_qualifiers = self.drop_param_qualifiers + if len(self.namespace) > 1: + drop_qualifiers = True + self.logger.debug( + "Qualified params cannot be passed to namespaced nodes and will be passed unqualified instead" + ) + + if self.use_sim_time: + final_cmd.extend(["-p", "use_sim_time:=true"]) + + for key, value in self._flat_params(drop_qualifiers).items(): + # Make sure the values are parseable for ROS + final_cmd.extend(["-p", f"{key}:={json.dumps(value)}"]) + + # If an env is specified ROS2 lets it completely replace the host env. We cover this + # through an additional flag, as often you just want to make certain overrides. + # https://github.com/ros2/launch/blob/rolling/launch/launch/descriptions/executable.py + if self.isolate_env: + final_env = self.env + else: + final_env = dict(os.environ) | self.env + + # All args must be strings + final_cmd = [str(s) for s in final_cmd] + + print_cmd = final_cmd + # for s in final_cmd[1:]: + # if len(s) > 50: + # s = s[:50] + "..." + # i = s.find("\n") + # if i > 0: + # s = s[:i] + "..." + # print_cmd.append(s) + + env_str = pformat(self.env, compact=True) + joint = " ".join(print_cmd) + if joint and len(joint) > 200: + joint = joint[:200] + "..." + self.logger.info(f"Starting process '{joint}', env={env_str}") + + # Start the node process + self._process = subprocess.Popen( + final_cmd, + cwd=None, + env=final_env, + shell=self.use_shell, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + # start in separate process group so it doesn't react to our sigint immediately + preexec_fn=os.setpgrp, + ) + + # Watch the process for output and react when it terminates + threading.Thread( + target=self._watch_process, + args=[self._process], + daemon=True, + ).start() + + except Exception: + self.logger.error( + f"An exception occurred while executing process:\n{traceback.format_exc()}" + ) + + def _watch_process( + self, + process: subprocess.Popen, + ) -> None: + # Non-blocking reads might miss some output, so we use blocking reads in + # separate threads instead + stdout_queue = queue.Queue() + stderr_queue = queue.Queue() + + # Same pattern as what our PrettyLogFormatter is looking for + ros_message_pattern = re.compile(ROSLOG_PATTERN_BL) + + def read_stream(stream, q): + try: + while True: + line = stream.readline() + if not line: # EOF + break + q.put(line.rstrip("\n\r")) + except Exception: + # Signal error/EOF + q.put(None) + + def collect_bundle(q): + nonlocal active_streams + try: + line = q.get(timeout=0.1) + if line is None: + active_streams -= 1 + return + + # Collect any additional lines that arrive immediately + bundle = [line] + while True: + try: + # Very short timeout to check for more lines + next_line = q.get(timeout=0.01) + if next_line is None: # EOF + active_streams -= 1 + break + + bundle.append(next_line) + except queue.Empty: + # No more lines immediately available + break + + return bundle + except queue.Empty: + pass + + # TODO could be nice, but suppressing output should be a user choice + def filter_tracebacks(bundle: list[str]) -> list[str]: + ret = [] + tb = [] + + for s in bundle: + if s.startswith("Traceback (most recent call last):"): + if tb: + ret.append("\n".join(tb)) + tb.clear() + + tb.append(s) + elif tb: + if not s.startswith(" "): + if "ExternalShutdownException" in s: + tb.clear() + ret.append("Node is shutting down: ExternalShutdownException") + elif "KeyboardInterrupt" in s: + tb.clear() + ret.append("Node is shutting down: KeyboardInterrupt") + else: + tb.append(s) + else: + tb.append(s) + else: + ret.append(s) + + if tb: + ret.append("\n".join(tb)) + + return ret + + def log_bundle(bundle: list[str], level: int): + # When receiving multiple lines of text at ones it is not guaranteed that they belong + # together - we don't know if they were generated by a single log call or multiple. + # By looking for our special pattern we can identify new log calls and emit them as + # separate messages. + seg = [] + for line in bundle: + if seg and ros_message_pattern.search(line): + # Emit and start a new record + self.logger.log(level, "\n".join(seg)) + seg = [line] + else: + seg.append(line) + if seg: + self.logger.log(level, "\n".join(seg)) + + # Output collection threads + stdout_thread = threading.Thread( + target=read_stream, + args=(process.stdout, stdout_queue), + daemon=True, + ) + stderr_thread = threading.Thread( + target=read_stream, + args=(process.stderr, stderr_queue), + daemon=True, + ) + + stdout_thread.start() + stderr_thread.start() + active_streams = 2 + + try: + # No need to check whether the process is still running, the collection threads + # will terminate once no more data can be collected and reduce active_streams + while active_streams > 0: + out_bundle = collect_bundle(stdout_queue) + if out_bundle: + log_bundle(out_bundle, logging.INFO) + + err_bundle = collect_bundle(stderr_queue) + if err_bundle: + # err_bundle = filter_tracebacks(err_bundle) + log_bundle(err_bundle, logging.ERROR) + + returncode = process.poll() + + if returncode == 0: + self.logger.warning("Process has finished cleanly") + else: + self.logger.critical(f"Process has died with exit code {returncode}") + + finally: + self.logger.info(f"Terminated: {self}") + + if self._on_exit_callback: + self._on_exit_callback() + + # Respawn the process if necessary + from better_launch import BetterLaunch + + if not BetterLaunch.instance().is_shutdown and ( + self.max_respawns < 0 or self._respawn_retries < self.max_respawns + ): + self.logger.info(f"Restarting {repr(self)} after unexpected shutdown") + + self._respawn_retries += 1 + if self.respawn_delay > 0.0: + time.sleep(self.respawn_delay) + + # Not nice: this will run start from the watcher thread, which will then create + # another watcher thread before this one here exits. Should be fine, just not + # elegant. + self.start() + else: + self._on_shutdown() + + def shutdown( + self, reason: str, signum: int = signal.SIGTERM, timeout: float = 0.0 + ) -> None: + if not self.is_running: + return + + signame = signal.Signals(signum).name + self.logger.warning(f"Received shutdown request: {reason} ({signame})") + + if signum == signal.SIGTERM and self._lifecycle_manager: + try: + self._lifecycle_manager.transition(LifecycleStage.FINALIZED) + except Exception as e: + self.logger.warning(f"Lifecycle transition to FINALIZED failed: {e}") + + self._on_signal(signum) + + if timeout == 0.0: + return + + try: + # Since introducing setpgrp above _process.wait hangs indefinitely + # self._process.wait(timeout) + os.waitpid(self.pid, 0) + except subprocess.TimeoutExpired: + raise TimeoutError("Node did not shutdown within the specified timeout") + + def _on_signal(self, signum) -> None: + if not self._process or self._process.poll() is not None: + return + + signame = signal.Signals(signum).name + + if not self.is_running: + # the process is done or is cleaning up, no need to signal + self.logger.info( + f"{signame} not sent to {repr(self)} because it is already closing" + ) + return + + if platform.system() == "Windows" and signum == signal.SIGINT: + # Windows doesn't handle sigterm correctly + self.logger.warning( + "SIGINT not supported on Windows, escalating to 'SIGTERM'" + ) + + signum = signal.SIGTERM + signame = signal.SIGTERM.name + + self.logger.info(f"Sending signal {signame} to {repr(self)}") + + try: + # os.killpg(self.pid, signum) + self._process.send_signal(signum) + except ProcessLookupError: + self.logger.info( + f"{signame} not sent to {repr(self)} because it has closed already" + ) + + def _on_shutdown(self) -> None: + if not self.is_running: + return + + # Send SIGTERM and SIGKILL if not shutting down fast enough + def escalate(): + try: + time.sleep(3.0) + self._on_signal(signal.SIGTERM) + time.sleep(3.0) + self._on_signal(signal.SIGKILL) + except Exception: + pass + + threading.Thread(target=escalate, daemon=True).start() + + def _get_info_section_general(self): + info = super()._get_info_section_general() + return ( + info + + f""" +\x1b[1mProcess\x1b[0m + PID: {self.pid} + Respawns: {self._respawn_retries} / {self.max_respawns} + Cmd Args: {self.cmd_args} + Env: {self.env} +""" + ) + + def __repr__(self) -> str: + return f"Node [name={self.name}, node={self.package}:{self.executable}, pid={self.pid}]" diff --git a/src/lib/better_launch/better_launch/elements/ros2_launch_wrapper.py b/src/lib/better_launch/better_launch/elements/ros2_launch_wrapper.py new file mode 100644 index 0000000000..150d9980f3 --- /dev/null +++ b/src/lib/better_launch/better_launch/elements/ros2_launch_wrapper.py @@ -0,0 +1,430 @@ +from typing import Any, Iterable +import os +import platform +import signal +import logging +import asyncio # keep this so we can use await and async def +import threading +from multiprocessing import Process, Queue, get_context +import subprocess +import osrf_pycommon.process_utils +from setproctitle import setproctitle, getproctitle + +from better_launch.utils.better_logging import ( + LogSink, + ROSLOG_PATTERN_BL, + ROSLOG_PATTERN_ROS, + PrettyLogFormatter, + RecordForwarder, + StubbornHandler, +) +from better_launch.utils import settings +from better_launch.utils.colors import get_contrast_color +from .abstract_node import AbstractNode + + +def _launchservice_worker( + name: str, + launchservice_args: list[Any], + launch_action_queue: Queue, + log_queue: Queue, + config: settings._Settings, +) -> None: + """This function will run in a child process and will not have access to any objects already in memory UNLESS they are passed to it as arguments. See the comments for further details.""" + # Makes it easier to tell what's going on in the process table + setproctitle(f"{getproctitle()} ({name})") + + if platform.system() != "Windows": + # On unix-based systems this will make this process independent from the host process. This + # way we can terminate it and its child processes without affecting the host process. Since + # it's not supported on windows, we handle this in our shutdown function instead. + os.setsid() + + # The child process will have clones of the host process' signal handlers installed (i.e. those + # defined in launch_this), so we should rewire these, otherwise we'll get parallel calls + def _on_sigint(signum: int, frame): + try: + ret = launch_service._shutdown(reason="SIGINT", due_to_sigint=True) + if ret: + # This way we suppress the "coroutine was never awaited" warning + ret.close() + finally: + # Recommended to use this special _exit call in child processes + os._exit(os.EX_OK) + + def _on_sigterm(signum: int, frame): + # Recommended to use this special _exit call in child processes + os._exit(os.EX_OK) + + signal.signal(signal.SIGINT, _on_sigint) + signal.signal(signal.SIGTERM, _on_sigterm) + + # Late import to avoid adding ROS2 launch as a dependency - we are committed here! + import launch + + # Synchronize the settings from the host process + settings._SETTINGS = config + + # LaunchService is a little stubborn about log formatting and always prepends the node's + # name and then appends the output format, but this also allows us to capture the actual + # source of the message + # NOTE this must be compatible with ROSLOG_PATTERN + os.environ["RCUTILS_CONSOLE_OUTPUT_FORMAT"] = ROSLOG_PATTERN_ROS + os.environ["RCUTILS_COLORIZED_OUTPUT"] = "0" + + # Create an offset to avoid going through the same sequence of colors as the host process + get_contrast_color.hue = 0.3 + + def handle_record(record: logging.LogRecord) -> None: + try: + log_queue.put_nowait(record) + except Exception: + # drop if parent not listening; prevents child spin + pass + + std_handler = RecordForwarder( + # TODO should always mimic the main process formatter configuration regarding colors etc. + PrettyLogFormatter( + config.screen_log_format, + roslog_pattern=r"\[(?P.+)] *" + ROSLOG_PATTERN_BL, + ) + ) + std_handler.add_listener(handle_record) + + # The ROS2 launch system will set new formatters for each node and source, so our formatter + # wouldn't be used. We either have to make our formatter more stubborn, or we run ROS2 + # in a proper subprocess and create a small launch file to load our actions. However, + # there is no easy way to serialize a launch description... + launch.logging.launch_config.screen_handler = StubbornHandler(std_handler) + logger = launch.logging.get_logger(name) + + launch_service = launch.LaunchService(argv=launchservice_args, noninteractive=True) + + # This feels highly illegal and I love it! :> + setattr( + launch_service, + f"_{launch_service.__class__.__name__}__logger", + logger, + ) + + # Handle the queue through which we are receiving new launch actions + loop = osrf_pycommon.process_utils.get_loop() + + def queue_watcher(): + while True: + # multiprocessing.Queue cannot be awaited, so instead we are using a thread to block + # indefinitely until we either receive something or the queue is closed. + ld = launch_action_queue.get() + + if ld is None: + # Most launch_service functions are thread safe and in fact DON'T seem + # to work when called using loop.call_soon_threadsafe + launch_service.shutdown() + break + + launch_service.include_launch_description(ld) + + async def run(): + logger.info("Starting ROS2 launch service") + + # Start queue_watcher AFTER the event loop is running + def start_queue_watcher(): + threading.Thread(target=queue_watcher, daemon=True).start() + + # Schedule queue_watcher to start after a brief delay + loop.call_soon(start_queue_watcher) + + await launch_service.run_async(shutdown_when_idle=False) + logger.info("ROS2 launch service has terminated") + + task = loop.create_task(run()) + + try: + loop.run_until_complete(task) + except Exception as e: + logger.warning(f"Error while running ROS2 launch service: {e}") + raise + + +class Ros2LaunchWrapper(AbstractNode): + def __init__( + self, + name: str = "LaunchService", + launchservice_args: list[str] = None, + output: LogSink | Iterable[LogSink] | Iterable[str] | str = LogSink.SCREEN, + ): + """Hosts a separate process running a ROS2 `LaunchService` instance (the main entrypoint of the ROS2 launch system). + + Note that although the ROS2 launch service may start an arbitrary number of nodes they will not be accessible for interaction beyond showing log output. Output on stdout and stderr from the process (and its nodes) will be captured, reformatted and separated by source. + + While this is a node-like object, it does not represent a node in ROS. This was done so that e.g. the TUI can be used to interact with the ROS2 launch system to e.g. include regular ROS2 launch files. Running a full process and a separate launch system is fairly resource heavy, however, `LaunchService` insists on running on the main thread. + + .. seealso:: + + `ROS2 LaunchService `_ + + Parameters + ---------- + name : str, optional + The name that will be used for the logger and the child process. + launchservice_args : list[str], optional + Additional arguments to pass to the ROS2 launch service. These will show up in the ROS2 `LaunchContext`. + output : LogSink | Iterable[LogSink] | Iterable[str] | str, optional + How log output from the launch service should be handled. This will also include the output from all nodes launched by this launch service. Common choices are `screen` to print to terminal, `log` to write to a common log file, `own_log` to write to a node-specific log file, and `none` to not write any output anywhere. See [configure_logger][utils.better_logging.configure_logger] for details. + """ + super().__init__( + "ros2/launch", + "launch_service.py", + name, + "/", + remaps=None, + params=None, + output=output, + ) + + self._launchservice_args = launchservice_args + + self._process: Process = None + self._launch_action_queue = Queue() + self._process_log_queue = Queue() + self._loaded_launch_descriptions = [] + self._shutdown_requested = False + self._terminate_requested = False + + @property + def pid(self) -> int: + """The process ID of the node process. Will be -1 if the process is not running.""" + if self._process: + return self._process.pid + return -1 + + @property + def launchservice_args(self) -> list[str]: + """Additional arguments that are passed to the launch service.""" + return self._launchservice_args + + @property + def is_running(self) -> bool: + try: + return self._process and self._process.is_alive() + except ValueError: + # For some reason we can't call is_alive when the process was closed + return False + + def is_ros2_connected(self, timeout: float = None) -> bool: + """Equal to [is_running][AbstractNode.is_running] for this class.""" + return self.is_running + + def is_lifecycle_node(self, timeout: float = None) -> bool: + """This is never a lifecycle node.""" + return False + + def queue_ros2_actions(self, *actions) -> None: + """Add ROS2 actions that will be loaded asynchronously by the launch service once it is running. Actions are bundled as a `LaunchDescription` before sending them off.""" + import launch + + ld = launch.LaunchDescription(list(actions)) + self._launch_action_queue.put(ld) + self._loaded_launch_descriptions.append(ld) + + def join(self, timeout: float = None) -> None: + """Wait for the underlying process to terminate and return its exit code. Returns immediately if the process is not running. + + Parameters + ---------- + timeout : float, optional + How long to wait for the process to finish. Wait forever if None. + + Returns + ------- + int + The exit code of the process, or None if it is already terminated. + + Raises + ------ + TimeoutError + If a timeout was specified and the process is still running by the time the timeout expires. + """ + proc = self._process + if proc: + try: + proc.join(timeout) + except subprocess.TimeoutExpired as e: + raise TimeoutError from e + + def start(self) -> None: + if self.is_running: + self.logger.warning(f"LaunchService {self.name} is alrady running") + return + + # Fix for https://github.com/dfki-ric/better_launch/issues/69 + # Python 3.14+ is switching to spawn as the default start method which means that our + # is-included guard in launch_this won't work anymore. Even if we used the env to pass + # flags across spawn boundaries we still need to maintain the BetterLaunch singleton, + # so for now fork is required. + # TODO check if we can handle this in @launch_this instead + ctx = get_context("fork") + + # Note that passing loggers will not work for the TUI, as they would have to communicate + # across the process boundaries. In general, only basic values and instances from the + # multiprocessing module should be passed to the process + self._process = ctx.Process( + target=_launchservice_worker, + args=( + self.name, + self.launchservice_args, + self._launch_action_queue, + self._process_log_queue, + settings.Settings(), + ), + name=self.name, + daemon=True, + ) + self._process.start() + + threading.Thread(target=self._process_watcher, daemon=True).start() + + def _process_watcher(self): + q = self._process_log_queue + + while self.is_running: + try: + # Blocking call, if we don't receive anything the queue was closed + record: logging.LogRecord = q.get() + if record is None: + break + + self.logger.handle(record) + except Exception as e: + self.logger.error(f"Receiving log record failed: {e}") + + self._process.close() + + def shutdown( + self, reason: str, signum: int = signal.SIGTERM, timeout: float = 0.0 + ) -> None: + if self._terminate_requested and self.is_running: + # Give the process a little bit of time to terminate + try: + self._process.join(0.5) + except Exception: + # Might fail during shutdown + pass + + if not self.is_running: + return + + try: + if self._terminate_requested or signum == signal.SIGKILL: + self.logger.warning( + f"{reason} - {self.name} was asked to terminate with SIGKILL. Killing the ROS2 launch service may leave stale processes behind!" + ) + # Set the child process and all its children on fire + self.send_signal(signal.SIGKILL) + elif self._shutdown_requested: + self._terminate_requested = True + self.logger.info( + f"{self.name} is still runing, escalating to SIGTERM ({reason})" + ) + # Rudely ask the child process and all its children to exit immediately + self.send_signal(signal.SIGTERM) + else: + self._shutdown_requested = True + self.logger.info( + f"Asking {self.name} to shutdown gracefully via SIGINT ({reason})" + ) + # Gently suggest to the child process and all its children that they could exit now + self.send_signal(signal.SIGINT) + except Exception: + pass + + if timeout == 0.0: + return + + try: + self._process.join(timeout) + except subprocess.TimeoutExpired: + raise TimeoutError( + "ROS2 launch service did not shutdown within the specified timeout" + ) + + def send_signal(self, signum: int) -> None: + if not self.is_running: + return + + if platform.system() == "Windows": + if signum == signal.SIGINT or signum == signal.SIGTERM: + subprocess.call(["taskkill", "/PID", str(self.pid), "/T"]) + elif signum == signal.SIGKILL: + subprocess.call(["taskkill", "/F", "/T", "/PID", str(self.pid)]) + else: + os.killpg(os.getpgid(self.pid), signum) + + def _get_info_section_general(self) -> str: + return ( + super()._get_info_section_general() + + f"""\ +\x1b[1mLaunch Service\x1b[0m + PID: {self.pid} +""" + ) + + def _get_info_section_ros(self) -> str: + return f"""\ +\x1b[1mLoaded Actions\x1b[0m +{self.describe_launch_actions()} +""" + + def describe_launch_actions(self) -> str: + """Returns a best-effort summary of all `LaunchDescriptions` that have been loaded so far. + + Since ROS2 does not provide a way to serialize its slew of launch actions (beyond *maybe* pickle), it is not possible to create a full representation without special handlers. However, *most* launch actions expose their relevant data as properties. This function will look for these and recurse into them if they return launch actions. + + Returns + ------- + str + A formatted and indented representation of the launch descriptions scheduled for loading thus far. + """ + descriptions = [] + + for idx, ld in enumerate(self._loaded_launch_descriptions): + info = f"({idx}) LaunchDescription([" + for entity in ld.describe_sub_entities(): + info += "\n " + self._format_properties(entity, 1) + "," + info += "\n])" + + descriptions.append(info) + + return "\n".join(descriptions) + + def _format_properties(self, entity, depth: int = 0): + indent = " " + description = f"{entity.__class__.__name__}(" + + for param in dir(entity.__class__): + if param == "self": + continue + + if param.startswith("_"): + continue + + val = getattr(type(entity), param) + if not isinstance(val, property): + continue + + val = val.fget(entity) + + # TODO some substitutions like LaunchDescriptionSource will only be resolved after + # they have been executed by ROS2 launch, but we need to do this on the side of the + # process, NOT in the host process where they are never run + if val is None: + val = "None" + elif val.__class__.__module__.startswith("launch"): + val = self._format_properties(val, depth + 1) + elif isinstance(val, str): + val = "'" + val + "'" + + description += f"\n{indent * (depth + 1)}{param} = {val}," + + description += f"\n{indent * depth})" + return description diff --git a/src/lib/better_launch/better_launch/gazebo.py b/src/lib/better_launch/better_launch/gazebo.py new file mode 100644 index 0000000000..4f4c0b58cf --- /dev/null +++ b/src/lib/better_launch/better_launch/gazebo.py @@ -0,0 +1,722 @@ +"""Additional functions for working with Gazebo, taking inspiration from simple_launch.""" + +__all__ = [ + "get_gazebo_version", + "gazebo_launch", + "save_world", + "spawn_model", + "spawn_topic_bridge", + "spawn_image_bridge", + "spawn_world_transform", + "get_gazebo_axes_args", + "GazeboBridge", +] + + +from typing import Any, Literal, Union +import os +import re +import shutil + +from ament_index_python.packages import ( + get_package_share_directory, + PackageNotFoundError, +) + +from better_launch import BetterLaunch +from better_launch.elements import Node +from .convenience import static_transform_publisher, read_robot_description + + +_gazebo_exec = None +_active_world = None + + +def get_gazebo_version() -> str: + """For a short time, Gazebo rebranded to "Ignition" before returning to Gazebo again. This function returns the associated prefix. + + Returns + ------- + str + The prefix indicating the type of Gazebo: "ign" for Ignition Gazebo or "gz" for Gazebo Classic. + """ + + for var in ["IGN_VERSION", "IGNITION_VERSION", "GZ_VERSION"]: + version = os.environ.get(var, None) + if version: + return "ign" if version == "fortress" else "gz" + + try: + get_package_share_directory("ros_gz_sim") + return "gz" + except PackageNotFoundError: + return "ign" + + +def get_gazebo_exec() -> str: + """Locates the gazebo executable. + + Returns + ------- + str + Full path to the gazebo executable. + + Raises + ------ + ValueError + If the executable cannot be located. + """ + global _gazebo_exec + + if not _gazebo_exec: + # On some systems ros_gz_sim was installed, but the executable was still ign + _gazebo_exec = shutil.which("gz") + + if not _gazebo_exec: + _gazebo_exec = shutil.which("ign") + + if not _gazebo_exec: + raise ValueError("Could not locate gazebo executable") + + return _gazebo_exec + + +def gazebo_launch( + package: str, + world_file: str, + gz_args: list[str] = None, + world_save_file: str = None, + save_after: float = 10.0, +) -> None: + """Starts a Gazebo simulation with the specified world. If `world_save_file` is specified, the world will be saved as an SDF to that file after `save_after` seconds, including base world and spawned entities. + + Parameters + ---------- + package : str, + A package to locate the world_file in. May be `None` (see [BetterLaunch.find][]). + world_file : str + Path to the primary world file for the simulation. + gz_args : _type_, optional + Optional arguments to pass to the Gazebo simulator. + world_save_file : _type_, optional + Path where the resulting SDF should be saved. + save_after : float, optional + Time in seconds after which the world SDF should be saved. + """ + bl = BetterLaunch.instance() + + world_file = bl.find(package, world_file) + if not os.path.exists(world_file): + raise ValueError("Could not find world file") + + full_args = [world_file] + if gz_args: + full_args += gz_args + + full_args = " ".join(full_args) + + if get_gazebo_version() == "gz": + launch_file = bl.find("ros_gz_sim", "gz_sim.launch.py") + launch_arguments = {"gz_args": full_args} + else: + launch_file = bl.find("ros_ign_gazebo", "ign_gazebo.launch.py") + launch_arguments = {"ign_args": full_args} + + # TODO It would be nice to avoid this include and launch gazebo without relying on an external + # launch file. However, the launch file does quite a bit, searching for packages that provide + # gazebo plugins and models + bl.include(None, launch_file, **launch_arguments) + + if world_save_file: + save_world(world_save_file, save_after) + + +def save_world(filepath: str, after: float = 5.0) -> None: + """Saves the current gazebo world to a file. Resolves any spawned URDF through their description parameter and converts to SDF. + + Parameters + ---------- + filepath : str + Path to the file to save the gazebo world to. + after : float, optional + How long to wait before saving. + """ + bl = BetterLaunch.instance() + + def save(): + bl.node( + package="better_launch", + executable="generate_gz_world", + params={"output_path": filepath}, + ) + + if after > 0.0: + bl.run_later(after, save) + else: + save() + + +def get_active_world_name(force_query: bool = False) -> str: + """Return the name of the currently loaded world. If it was not loaded from better_launch, Gazebo will be asked directly. + + Parameters + ---------- + force_query : bool, optional + If True, don't use the cached value. + + Returns + ------- + str + The name of a Gazebo world. + + Raises + ------ + ValueError + If querying Gazebo for the world name failed. + """ + global _active_world + + if _active_world and not force_query: + return _active_world + + try: + output = BetterLaunch.exec([get_gazebo_exec(), "model", "--list"]) + except Exception as e: + raise ValueError(f"Failed to list loaded Gazebo models: {e}") + + if not output or "timed out" in output: + raise ValueError("No loaded Gazebo models or request timed out") + + world_name = output.replace("]", "[").split("[")[1] + + if not world_name: + raise ValueError( + f"Gazebo did not return a parseable world name (output was '{output}')" + ) + + _active_world = world_name + return _active_world + + +def get_model_prefix(model: str) -> str: + """ + Construct the Gazebo prefix string for the given model name. + + Parameters + ---------- + model : str + The name of the model. + + Returns + ------- + str + A string representing the model's prefix in the Gazebo world. + """ + return f"/world/{get_active_world_name()}/model/{model}" + + +def get_model_topic(model: str, topic: str) -> str: + """ + Constructs a string representing the model topic in Gazebo. + + Parameters + ---------- + model : str + The model name. + topic : str + The topic name related to the model. + + Returns + ------- + str + The path of the given model's specified topic. + """ + # TODO verify: simple_launch only returns f"/model/{model}/{topic}", but that seems wrong + return f"{get_model_prefix(model)}/{topic}" + + +def get_gazebo_axes_args( + x: float = 0.0, + y: float = 0.0, + z: float = 0.0, + yaw: float = 0.0, + pitch: float = 0.0, + roll: float = 0.0, +) -> dict[str, float]: + """Constructs a list of command-line arguments that can be used e.g. when spawning a new model. + + Parameters + ---------- + x : float, optional + translation x component + y : float, optional + translation y component + z : float, optional + translation z component + yaw : float, optional + rotation yaw component + pitch : float, optional + rotation pitch component + roll : float, optional + rotation roll component + + Returns + ------- + dict[str, float] + A dictionary containing the axes names and values. The axes names correspond to what Gazebo expects on the command line and so can be passed to e.g. [spawn_model][]. + """ + return { + "x": x, + "y": y, + "z": z, + "Y": yaw, + "P": pitch, + "R": roll, + } + + +def spawn_model( + model_name: str, + model: str, + model_source: Literal["topic", "file", "string", "param", "auto"] = "auto", + spawn_args: dict[str, Any] = None, + *, + pass_by_topic: bool = False, + topic_base_name: str = "/gazebo/models/", + xacro_args: list[str] = None, +) -> Node: + """ + Spawns a model into Gazebo under the given name from the specified topic or file. + + The `spawn_args` dictionary can include additional options, such as the initial pose of the model. + + Note that when spawning robot models this way, they typically also need a [convenience.joint_state_publisher][]. + + Parameters + ---------- + model_name : str + The name of the model to spawn in the Gazebo environment. + model : str + The model to spawn. The contents of this string depend on the model_source, but should ultimately lead to a full XML description. + model_source : str + Where to read the model from. Auto will try to guess the source based on the content of `model`: does it look like XML, is it a file, is it an existing topic? Otherwise assume it's a ROS2 parameter. + spawn_args : dict[str, Any], optional + Additional arguments for spawning the model, such as pose and other options. See [get_gazebo_axes_args][] for defining the model's orientation. + pass_by_topic : bool, optional + If True and the model is a file or string, pass it by topic instead. On ROS versions before lyrical this feature is disabled. + topic_base_name : str, optional + Use this as the base topic string when `pass_by_topic` is True. The `model_name` will be appended to form the full topic. + xacro_args : list[str], optional + Arguments to pass to the xacro parser if a xacro file is passed as the model. + + Returns + ------- + Node + The spawned node instance. + """ + bl = BetterLaunch.instance() + + if bl.ros_distro_key() < "l": + pass_by_topic = False + + if spawn_args is None: + spawn_args = {} + + if model_source == "auto": + if " Node: + """ + Runs a static_transform_publisher to connect the ROS `world` frame and a Gazebo world frame, if the Gazebo world frame is specified and different from 'world'. + + Parameters + ---------- + gazebo_world_frame : str, optional + The name of the Gazebo world frame. Uses [get_active_world_name][] if None. + + Returns + ------- + Node + The spawned node instance. + """ + if gazebo_world_frame is None: + gazebo_world_frame = get_active_world_name() + + if gazebo_world_frame != "world": + return static_transform_publisher("world", gazebo_world_frame) + + return None + + +def spawn_topic_bridge( + *bridges: Union[str, "GazeboBridge"], + node_name: str = "gz_bridge", + remaps: dict[str, str] = None, + cmd_args: list[str] = None, + **kwargs, +) -> Node: + """Start a Gazebo topic bridge to relay messages between ROS2 and Gazebo. + + Note that there is a separate function for bridging image topics more efficiently. See [spawn_image_bridge][]. + + Parameters + ---------- + bridges : list[str | GazeboBridge] + Definitions of topic bridges. This can be either a typical string (`@`) or a [GazeboBridge][] instance. Note that in order to bridge services you will have to specify them as strings for now. + node_name : str, optional + The name of the bridge node. + remaps : dict[str, str], optional + Any topic remaps you wish to apply to the bridge. + cmd_args : list[str], optional + Additional command line arguments to the gazebo bridge executable. + kwargs : dict[str, Any] + Additional node arguments. + + Returns + ------- + Node + The node running the bridge process. + """ + ros_gz = "ros_" + get_gazebo_version() + bl = BetterLaunch.instance() + + all_remaps = {} + + bridges: list[str] = list(bridges) + for i, b in enumerate(bridges): + if not isinstance(b, GazeboBridge): + b = GazeboBridge.from_string(b) + bridges[i] = b + + if b.remaps: + all_remaps.update(b.remaps) + + if remaps: + all_remaps.update(remaps) + + args = [str(b) for b in bridges] + if cmd_args: + args.extend(cmd_args) + + return bl.node( + f"{ros_gz}_bridge", + "parameter_bridge", + node_name, + cmd_args=args, + remaps=all_remaps, + **kwargs, + ) + + +def spawn_image_bridge( + *bridges: Union[str, "GazeboBridge"], + node_name: str = None, + remaps: dict[str, str] = None, + cmd_args: list[str] = None, + qos: str = None, + **kwargs, +) -> Node: + """Spawn a bridge to efficiently relay images from Gazebo to ROS2 (one direction only). + + Parameters + ---------- + bridges : list[str | GazeboBridge] + The image topics to bridge. These can be specified as either Gazebo bridge definitions, [GazeboBridge][] instances. Also accepts regular topics, in which case the type is assumed to be `sensor_msgs/Image`. + node_name : str, optional + The name of the node running the bridge. + remaps : dict[str, str], optional + How topics should be remapped in ROS2. + cmd_args : list[str], optional + Additional commandline arguments to the bridge executable. + qos : str, optional + The `ROS2 quality of service `_ definition to use. Note that this is not supported in all versions of ros-gz-image and may cause the bridge to terminate. + kwargs : dict[str, Any] + Additional node arguments. + + Returns + ------- + Node + The node running the bridge process. + + Raises + ------ + ValueError + If a topic is passed which is not an image topic. + """ + all_remaps = {} + + bridges: list[GazeboBridge] = list(bridges) + for i, b in enumerate(bridges): + if not isinstance(b, GazeboBridge): + try: + b = GazeboBridge.from_string(b) + except ValueError: + b = GazeboBridge.from_string(f"{b}@sensor_msgs/msg/Image]gz.msgs.Image") + + bridges[i] = b + + if not b.is_image_bridge: + raise ValueError(f"{b} is not an image bridge") + + if b.remaps: + all_remaps.update(b.remaps) + + if remaps: + all_remaps.update(remaps) + + for src in bridges: + if src.topic in all_remaps: + dst = all_remaps[src.topic] + for ext in ("/compressed", "/compressedDepth", "/theora"): + all_remaps.setdefault(src.topic + ext, dst + ext) + + args = [b.topic for b in bridges] + + if cmd_args: + args.extend(cmd_args) + + if qos: + args.extend(["--ros-args", f"qos:={qos}"]) + + ros_gz = "ros_" + get_gazebo_version() + bl = BetterLaunch.instance() + + return bl.node( + f"{ros_gz}_image", + "image_bridge", + node_name, + remaps=all_remaps, + cmd_args=args, + **kwargs, + ) + + +class GazeboBridge: + gazebo_message_types = { + "actuator_msgs/msg/Actuators": "gz.msgs.Actuators", + "builtin_interfaces/msg/Time": "gz.msgs.Time", + "geometry_msgs/msg/Point": "gz.msgs.Vector3d", + "geometry_msgs/msg/Pose": "gz.msgs.Pose", + "geometry_msgs/msg/PoseArray": "gz.msgs.Pose_V", + "geometry_msgs/msg/PoseStamped": "gz.msgs.Pose", + "geometry_msgs/msg/PoseWithCovariance": "gz.msgs.PoseWithCovariance", + "geometry_msgs/msg/PoseWithCovarianceStamped": "gz.msgs.PoseWithCovariance", + "geometry_msgs/msg/Quaternion": "gz.msgs.Quaternion", + "geometry_msgs/msg/Transform": "gz.msgs.Pose", + "geometry_msgs/msg/TransformStamped": "gz.msgs.Pose", + "geometry_msgs/msg/Twist": "gz.msgs.Twist", + "geometry_msgs/msg/TwistStamped": "gz.msgs.Twist", + "geometry_msgs/msg/TwistWithCovariance": "gz.msgs.TwistWithCovariance", + "geometry_msgs/msg/TwistWithCovarianceStamped": "gz.msgs.TwistWithCovariance", + "geometry_msgs/msg/Vector3": "gz.msgs.Vector3d", + "geometry_msgs/msg/Wrench": "gz.msgs.Wrench", + "geometry_msgs/msg/WrenchStamped": "gz.msgs.Wrench", + "gps_msgs/msg/GPSFix": "gz.msgs.NavSat", + "nav_msgs/msg/Odometry": "gz.msgs.OdometryWithCovariance", + "rcl_interfaces/msg/ParameterValue": "gz.msgs.Any", + "ros_gz_interfaces/msg/Altimeter": "gz.msgs.Altimeter", + "ros_gz_interfaces/msg/Contact": "gz.msgs.Contact", + "ros_gz_interfaces/msg/Contacts": "gz.msgs.Contacts", + "ros_gz_interfaces/msg/Dataframe": "gz.msgs.Dataframe", + "ros_gz_interfaces/msg/Entity": "gz.msgs.Entity", + "ros_gz_interfaces/msg/Float32Array": "gz.msgs.Float_V", + "ros_gz_interfaces/msg/GuiCamera": "gz.msgs.GUICamera", + "ros_gz_interfaces/msg/JointWrench": "gz.msgs.JointWrench", + "ros_gz_interfaces/msg/Light": "gz.msgs.Light", + "ros_gz_interfaces/msg/ParamVec": "gz.msgs.Param", + "ros_gz_interfaces/msg/SensorNoise": "gz.msgs.SensorNoise", + "ros_gz_interfaces/msg/StringVec": "gz.msgs.StringMsg_V", + "ros_gz_interfaces/msg/TrackVisual": "gz.msgs.TrackVisual", + "ros_gz_interfaces/msg/VideoRecord": "gz.msgs.VideoRecord", + "rosgraph_msgs/msg/Clock": "gz.msgs.Clock", + "sensor_msgs/msg/BatteryState": "gz.msgs.BatteryState", + "sensor_msgs/msg/CameraInfo": "gz.msgs.CameraInfo", + "sensor_msgs/msg/FluidPressure": "gz.msgs.FluidPressure", + "sensor_msgs/msg/Image": "gz.msgs.Image", + "sensor_msgs/msg/Imu": "gz.msgs.IMU", + "sensor_msgs/msg/JointState": "gz.msgs.Model", + "sensor_msgs/msg/Joy": "gz.msgs.Joy", + "sensor_msgs/msg/LaserScan": "gz.msgs.LaserScan", + "sensor_msgs/msg/MagneticField": "gz.msgs.Magnetometer", + "sensor_msgs/msg/NavSatFix": "gz.msgs.NavSat", + "sensor_msgs/msg/PointCloud2": "gz.msgs.PointCloudPacked", + "std_msgs/msg/Bool": "gz.msgs.Boolean", + "std_msgs/msg/ColorRGBA": "gz.msgs.Color", + "std_msgs/msg/Empty": "gz.msgs.Empty", + "std_msgs/msg/Float32": "gz.msgs.Float", + "std_msgs/msg/Float64": "gz.msgs.Double", + "std_msgs/msg/Header": "gz.msgs.Header", + "std_msgs/msg/Int32": "gz.msgs.Int32", + "std_msgs/msg/String": "gz.msgs.StringMsg", + "std_msgs/msg/UInt32": "gz.msgs.UInt32", + "tf2_msgs/msg/TFMessage": "gz.msgs.Pose_V", + "trajectory_msgs/msg/JointTrajectory": "gz.msgs.JointTrajectory", + "vision_msgs/msg/Detection2D": "gz.msgs.AnnotatedAxisAligned2DBox", + "vision_msgs/msg/Detection2DArray": "gz.msgs.AnnotatedAxisAligned2DBox_V", + } + """Map from ROS2 message types to Gazebo message types.""" + + @classmethod + def from_string(cls, bridge: str, remaps: dict[str, str] = None) -> "GazeboBridge": + """Extract the topic, ROS2 message type, direction, and gazebo message type from a bridge definition string. + + Parameters + ---------- + bridge : str + A bridge definition following the pattern `@`. + remaps : str, optional + Topic remaps the bridge should use once it is started. + + Returns + ------- + GazeboBridge + An instance with the aforementionend parameters. + + Raises + ------ + ValueError + If the bridge string doesn't match the expected pattern. + """ + m = re.match(r"(.+)@(.+)([\[\]@])(.+)", bridge) + + if not m: + raise ValueError(bridge + " is not a valid bridge string") + + return GazeboBridge( + m.group(1), m.group(2), m.group(3), m.group(4), remaps=remaps + ) + + @classmethod + def clock_bridge(cls) -> "GazeboBridge": + """ + Creates a GazeboBridge instance for bridging the /clock topic from Gazebo to ROS. + + Returns + ------- + GazeboBridge + An instance of the GazeboBridge for the clock topic. + """ + return GazeboBridge("/clock", "rosgraph_msgs/msg/Clock", "gz2ros") + + @classmethod + def joint_state_bridge(cls, model: str) -> "GazeboBridge": + """ + Creates a GazeboBridge instance for the bridging the joint states of a given model to ROS. + + Parameters + ---------- + model : str + The model name to associate with the joint state topic. + + Returns + ------- + GazeboBridge + An instance of the GazeboBridge for the joint state topic. + """ + topic = get_model_topic(model, "joint_state") + return GazeboBridge( + topic, "sensor_msgs/JointState", "gz2ros", remaps={topic: "joint_states"} + ) + + def __init__( + self, + topic: str, + ros2_type: str = None, + direction: Literal[ + "ros2gz", "gz2ros", "bidirectional", "[", "]", "@" + ] = "bidirectional", + gazebo_type: str = None, + *, + remaps: dict[str, str] = None, + ): + """Create a definition for a gazebo bridge. Convert to a string in order to get the canonical Gazebo bridge representation. To start a topic bridge see [spawn_topic_bridge][]. + + Parameters + ---------- + topic : str + The ROS2 topic to bridge into Gazebo. Any remaps have to happen on the ROS2 side. + ros2_type : str, optional + The message type of the ROS2 topic. If not provided the type will be looked up in the currently published topics. + direction : str, optional + The direction in which messages will be passed, + gazebo_type : str, optional + The message type of the Gazebo topic. If not provided it will be looked up from the common [GazeboBridge.gazebo_message_types][]. + remaps : dict[str, str], optional + Additional topic remaps for the bridge node. + + Raises + ------ + ValueError + If an invalid direction is provided, or if the ROS2 message type was not specified and could not be looked up. + """ + if direction == "ros2gz": + direction = "]" + elif direction == "gz2ros": + direction = "[" + elif direction == "bidirectional": + direction = "@" + + if not ros2_type: + bl = BetterLaunch.instance() + live_topics = bl.shared_node.get_topic_names_and_types() + + if topic not in live_topics: + raise ValueError( + f"Message type not specified and topic {topic} does not exist yet" + ) + + ros2_type = live_topics[topic][0] + + if direction not in "[]@": + raise ValueError(f"Invalid direction {direction}") + + if not gazebo_type: + gazebo_type = self.gazebo_message_types[ros2_type] + + self.topic = topic + self.ros2_type = ros2_type + self.direction = direction + self.gazebo_type = gazebo_type + self.remaps = remaps + self.is_image_bridge = ros2_type == "sensor_msgs/msg/Image" + + def __str__(self): + return f"{self.topic}@{self.ros2_type}{self.direction}{self.gazebo_type}" diff --git a/src/lib/better_launch/better_launch/launcher.py b/src/lib/better_launch/better_launch/launcher.py new file mode 100644 index 0000000000..382b621a3f --- /dev/null +++ b/src/lib/better_launch/better_launch/launcher.py @@ -0,0 +1,2389 @@ +from typing import Any, Callable, Generator, Iterable, Literal, TYPE_CHECKING +import importlib +import sys +import os +import re +import signal +import inspect +import time +import threading +import subprocess +import shlex +import shutil +from fnmatch import fnmatch +from pathlib import Path +from concurrent.futures import Future, CancelledError, TimeoutError +from contextlib import contextmanager +import logging +import yaml +import secrets + +from rclpy.node import ( + Node as RosNode, + Service as RosServiceProvider, + Client as RosServiceClient, + Publisher as RosPublisher, + Subscription as RosSubscriber, +) +from rclpy.qos import ( + QoSProfile, + HistoryPolicy, + ReliabilityPolicy, + DurabilityPolicy, + LivelinessPolicy, + qos_profile_services_default, +) +from ament_index_python.packages import get_package_prefix, get_package_share_directory + +if TYPE_CHECKING: + # Surprisingly large imports, so we only import them if we actually need them + from rclpy.action import ( + ActionServer as RosActionServer, + ActionClient as RosActionClient, + ) + + +from . import __version__ +from better_launch.elements import ( + Group, + AbstractNode, + Node, + Composer, + Component, + LifecycleStage, + Ros2LaunchWrapper, + ForeignNode, + get_package_for_path, + find_process_for_node, + find_foreign_nodes, +) +from better_launch.utils.introspection import ( + find_function_frame, + find_calling_frame, + find_launchthis_function, +) +from better_launch.utils.settings import Settings, severity_to_loglevel +from better_launch.utils.better_logging import LogSink +from better_launch.utils.random_names import get_unique_word +from better_launch.utils.glob_dict import glob_dict, merge_and_explode +from better_launch.ros.ros_adapter import ROSAdapter +from better_launch.ros import logging as roslog + +_bl_singleton_instance = "__better_launch_instance" +_bl_include_args = "__better_launch_include_args" +_unset = object() + + +class BetterLaunchMeta(type): + _singleton_future = Future() + + # Allows (and enforces) reusing an already existing BetterLaunch instance. + # Important for launch file includes. + def __call__(cls, *args, **kwargs): + existing_instance = globals().get(_bl_singleton_instance, None) + if existing_instance is not None: + return existing_instance + + obj = cls.__new__(cls, *args, **kwargs) + globals()[_bl_singleton_instance] = obj + obj.__init__(*args, **kwargs) + + cls._singleton_future.set_result(obj) + return obj + + def instance(cls) -> "BetterLaunch": + """Immediately retrieve the BetterLaunch singleton instance. + + Returns + ------- + BetterLaunch + The BetterLaunch singleton instance, or None if it doesn't exist yet. + """ + try: + return cls._singleton_future.result(0.0) + except TimeoutError: + return None + + def wait_for_instance(cls, timeout: float = None) -> "BetterLaunch": + """Retrieve the BetterLaunch singleton instance as soon as possible. + + Parameters + ---------- + timeout : float, optional + How long to wait for the singleton instance to appear. Wait forever if timeout is None. Don't wait at all if timeout is 0.0. + + Returns + ------- + BetterLaunch + The BetterLaunch singleton instance. + + Raises + ------ + TimeoutError + If the timeout has passed and no instance has appeared yet. + """ + return cls._singleton_future.result(timeout) + + +class BetterLaunch(metaclass=BetterLaunchMeta): + """This should be all you need to create beautiful, simple and convenient launch files!""" + + _launchfile: str = None + _launch_func_args: dict[str, Any] = {} + + def __init__( + self, + name: str = None, + launch_args: dict[str, Any] = None, + root_namespace: str = "/", + *, + pass_launch_func_default: bool = True, + short_unique_names: bool = False, + ): + """Note that BetterLaunch is a singleton: only the first invocation to `__init__` will succeed. All subsequent calls will return the previous instance. If you need access to the BetterLaunch instance outside your launch function, consider using one of the following classmethods instead: + + - [BetterLaunch.instance][BetterLaunchMeta.instance] + - [BetterLaunch.wait_for_instance][BetterLaunchMeta.wait_for_instance] + + Parameters + ---------- + name : str, optional + The name of this instance, will default to the launchfile's filename. + launch_args : dict, optional + Override the launch arguments BetterLaunch has access to. By default this will be the launch function's arguments. These will mainly be used for passing to included launch files. + root_namespace : str, optional + The namespace of the root group. + short_unique_names : bool, optional + If True, use short random hex strings for unique names instead of random words. + """ + if not name: + if not BetterLaunch._launchfile: + frame = find_calling_frame(self.__init__) + BetterLaunch._launchfile = frame.filename + name = os.path.basename(BetterLaunch._launchfile) + + # roslog.launch_config must be setup before instantiation of BetterLaunch + self.logger = roslog.get_logger(name) + + if launch_args is not None: + BetterLaunch._launch_func_args = launch_args + + # For those cases where we need to interact with ROS (e.g. service calls) + self._ros_adapter: ROSAdapter = None + + if root_namespace is None: + root_namespace = "/" + root_namespace = "/" + root_namespace.strip("/") + + # Intentionally not exposed as an init argument, as it wouldn't (and shouldn't) + # have an effect when an instance is retrieved in an included launch file + use_sim_time = Settings().use_sim_time + self._group_root = Group(None, root_namespace, use_sim_time) + self._group_stack = [self._group_root] + + self._composition_node = None + + # Allows to run traditional ros2 launch actions and descriptions + self._ros2_launcher = None + + self._sigint_received = False + self._sigterm_received = False + self._shutdown_future = Future() + self._shutdown_callbacks = [] + + self.short_unique_names = short_unique_names + self.pass_launch_func_default = pass_launch_func_default + + self.hello() + + def hello(self) -> None: + """Prints our welcome message and some useful information. + Note that this will not appear in the logs! + """ + + # config_str = "\n".join( + # f"{key}={val}" for key, val in Settings().as_dict().items() + # ) + # \x1b[94;20mSettings:\x1b[0m + # {config_str} + + # Ascii art based on: https://asciiart.cc/view/10677 + msg = f""" +\x1b[1;20mBetter Launch v{__version__} is starting!\x1b[0m +Please fasten your seatbelts and secure all baggage underneath your chair. + +\x1b[94;20mLaunchfile:\x1b[0m +{self.launchfile} + +\x1b[94;20mLogs:\x1b[0m +{roslog.launch_config.log_dir} + +\x1b[94;20mTakeoff in 3... 2... 1...\x1b[0m + + * ,: + + ,' | + + / : + * --' / ++ \\/ /:/ + * / ://_\\ + + __/ / + - )'-. / + ./ :\\ + * /.' ' + '/' + ' + + .-"- + ( ) + . .-' '. + ( (. )8: + .' _ / (_ ) '._ +""" + # We don't want to log this + print(msg) + + def spin(self, exit_with_last_node: bool = True) -> None: + """Join the BetterLaunch thread until it terminates. You do **not** need to call this if you're using the [launch_this][] wrapper or the TUI. + + Parameters + ---------- + exit_with_last_node : bool, optional + If True this function will return when all nodes have been stopped. + """ + if exit_with_last_node: + while not self._shutdown_future.done(): + nodes = self.get_nodes( + include_components=True, + include_launch_service=True, + include_foreign=False, + ) + + if all(not n.is_running for n in nodes): + self.shutdown("all nodes have stopped") + break + + # Sleep until every node has terminated, then check again + for n in nodes: + n.join() + else: + try: + self._shutdown_future.result() + except (CancelledError, TimeoutError): + pass + + print( + f"\n => \x1b[94;20mReminder:\x1b[0m log files were saved at {roslog.launch_config.log_dir}" + ) + + def get_unique_name(self, name: str = "", check_running_nodes: bool = True) -> str: + """Returns a unique name. If a name is provided it will be prepended with an underscore. + + Parameters + ---------- + name : str, optional + The string to use as the base. + check_running_nodes : bool, optional + If true, check the currently running ROS2 nodes for name collisions. + + Returns + ------- + str + A unique name. + """ + node_names = set() + if check_running_nodes: + nodes = self.get_nodes(include_components=True, include_foreign=True) + node_names.update(n.name for n in nodes) + + while True: + if self.short_unique_names: + u = secrets.token_hex(2) + else: + u = get_unique_word() + + if name: + u = name + "_" + u + + if u not in node_names: + return u + + def get_groups(self) -> list[Group]: + """Returns a list of all groups in the order they were created. + + Returns + ------- + list[Group] + All groups added so far. + """ + # Assemble all groups + groups: list[Group] = [self.group_root] + queue: list[Group] = [self.group_root] + + # Simplified breadth first search since we don't expect any loops + while queue: + g = queue.pop() + groups.extend(g.children.values()) + queue.extend(g.children.values()) + + return groups + + def get_nodes( + self, + *, + include_components: bool = False, + include_launch_service: bool = True, + include_foreign: bool = False, + ) -> list[AbstractNode]: + """Returns a list of all nodes in the order they were added. Components will be added right after their composers. + + Note that this will only return nodes that can be managed by better_launch. If a node process creates multiple nodes only the first node can be discovered, as ROS2 does not provide an API linking a node to its process (or even just its package). + + Parameters + ---------- + include_components : bool, optional + Whether to include [Component][] instances. This will *not* include components that have been loaded from outside (e.g. `ros2 component load`). + include_launch_service : bool, optional + Whether to include the ROS2 launch service wrapper if it was created. Will be included after the regular nodes and before the foreign nodes. + include_foreign : bool, optional + Whether to include foreign nodes that have not been started by this launcher instance. Will be appended at the end of the returned nodes. However, take heed of the above warning regarding node discovery. + + Returns + ------- + list[AbstractNode] + A list of all nodes, sorted by when they were added. + """ + nodes = [] + groups = self.get_groups() + + for g in groups: + for n in g.nodes: + nodes.append(n) + if include_components and isinstance(n, Composer): + # Components may have been added from outside (e.g. ros2 control load) + nodes.extend(n.managed_components) + + if include_launch_service and self._ros2_launcher: + nodes.append(self._ros2_launcher) + + if include_foreign: + # Note that self.shared_node.get_node_names_and_namespaces() will only give us the + # names and namespaces, but no handle on the actual processes, not even a package + + # TODO should check if it's a composer and has subnodes, but we won't know the + # components' handles or plugins... + # if include_components: + # for f in foreign: + # if Composer.is_composer(f): + # composer = Composer(f) + # for cid, component in composer.get_live_components().items(): + # nodes.append(Component(...)) + + foreign = self.get_foreign_nodes() + nodes.extend(foreign) + + return nodes + + def get_foreign_nodes(self) -> list[ForeignNode]: + """Lists all running nodes that have a process but have not been started by this better_launch process. + + Note however that if a process starts multiple nodes, only the first node can be discovered. This is because ROS2 does not provide an API for getting the process parameters from a node. + + Returns + ------- + list[ForeignNode] + All nodes with a process that have not been started by this better_launch process. + """ + return find_foreign_nodes() + + def all_ros2_node_names(self) -> list[str]: + """Returns a list of all currently registered node's full names (namespace + name). + + This list is guaranteed to be complete as far as ROS2 is concerned. If you require a node object you can actually interact with consider using [query_node][] or [get_nodes][] instead. + + Returns + ------- + list[str] + A list of all running nodes' full names. + """ + return [ + f"{n[1].rstrip('/')}/{n[0]}" + for n in self.shared_node.get_node_names_and_namespaces() + ] + + def query_node( + self, + pattern: str, + *, + include_components: bool = True, + include_launch_service: bool = False, + include_foreign: bool = False, + ) -> AbstractNode: + """Retrieve the first node matching the provided pattern. + + Parameters + ---------- + pattern : str + Either the name of a node, or a qualified node name (i.e. namespace + name). If a namespace is included it must be absolute, but may include `*` or `**` wildcards to skip one or more groups (via `fnmatch`). + include_components : bool, optional + Whether to include components in the results, if any. + include_launch_service : bool, optional + Whether to include the ROS2 launch service in the result (if it exists). + include_foreign : bool, optional + Whether to include foreign nodes not created by this launcher. + + Returns + ------- + AbstractNode + The first node matching the provided pattern, or None if none matched. + """ + for node in self.get_nodes( + include_components=include_components, + include_launch_service=include_launch_service, + include_foreign=include_foreign, + ): + if node.name == pattern or fnmatch(node.fullname, pattern): + return node + + return None + + def query_nodes( + self, + pattern: str, + *, + include_components: bool = True, + include_launch_service: bool = False, + include_foreign: bool = False, + ) -> Generator[AbstractNode, None, None]: + """Yield all nodes matching the provided pattern. + + Parameters + ---------- + pattern : str + Either the name of a node, or a qualified node name (i.e. namespace + name). If a namespace is included it must be absolute, but may include `*` or `**` wildcards to skip one or more groups (via `fnmatch`). + include_components : bool, optional + Whether to include components in the results, if any. + include_launch_service : bool, optional + Whether to include the ROS2 launch service in the result (if it exists). + include_foreign : bool, optional + Whether to include foreign nodes not created by this launcher. + + Returns + ------- + Generator[AbstractNode, None, None] + The nodes matching the pattern. + """ + for node in self.get_nodes( + include_components=include_components, + include_launch_service=include_launch_service, + include_foreign=include_foreign, + ): + if node.name == pattern or fnmatch(node.fullname, pattern): + yield node + + @staticmethod + def ros_distro() -> str: + """Returns the name of the currently sourced ros distro (i.e. *$ROS_DISTRO*).""" + return os.environ["ROS_DISTRO"] + + @staticmethod + def ros_distro_key() -> str: + return BetterLaunch.ros_distro()[0].lower() + + @property + def launchfile(self) -> str: + """The path of the (main) *better_launch* launchfile being executed.""" + return BetterLaunch._launchfile + + @property + def launch_args(self) -> dict[str, Any]: + """All key-value pairs that have been passed to the launch function.""" + return BetterLaunch._launch_func_args + + @property + def ros_adapter(self) -> ROSAdapter: + """Contains and runs a shared ROS2 node to interact with topics, services, etc. The adapter is instantiated lazily as it brings a major performance hit (about 6 MiB memory and a high CPU spike).""" + if not self._ros_adapter or not self._ros_adapter._thread.is_alive(): + self._ros_adapter = ROSAdapter() + + return self._ros_adapter + + @property + def shared_node(self) -> RosNode: + """A ROS2 node instance that can be used for creating publishers, services, etc.""" + return self.ros_adapter.ros_node + + @property + def group_root(self) -> Group: + """The root group ("/").""" + return self._group_stack[0] + + @property + def group_tip(self) -> Group: + """The most recent group.""" + return self._group_stack[-1] + + # TODO remove? + def find_group_for_namespace(self, namespace: str, create: bool = False) -> Group: + """Find the group representing the passed namespace. + + Parameters + ---------- + namespace : str + The namespace in question. + create : bool + If True, create missing groups along the way. + + Returns + ------- + Group + The group representing the final segment of the namespace, or None if no such group exists and create == False. + """ + if not namespace or namespace == "/": + return self.group_root + + g = self.group_root + + for part in namespace.split("/"): + child = g.children.get(part) + if not child: + if not create: + return None + + child = Group(g, part) + g.add_child(child) + + g = child + + return g + + def _on_sigint(self, sig: int, frame: inspect.FrameInfo) -> None: + if not self._sigint_received: + self.logger.warning("Received (SIGINT), forwarding to child processes...") + self._sigint_received = True + self.shutdown("user interrupt", signal.SIGINT) + else: + self.logger.warning("Received (SIGINT) again, escalating to sigterm") + self._on_sigterm(sig, frame) + + def _on_sigterm(self, sig: int, frame: inspect.FrameInfo) -> None: + if self._sigterm_received: + try: + self.logger.critical("(SIGTERM) received again, terminating process") + finally: + sys.exit(-1) + + self._sigterm_received = True + self.logger.error("Using (SIGTERM) can result in orphaned processes!") + + # Final chance for the processes to shut down, but we will no longer wait + self.shutdown("received (SIGTERM)", signal.SIGTERM) + + if not self.is_shutdown: + self._shutdown_future.cancel() + + @property + def is_shutdown(self) -> bool: + """Whether *better_launch* has shutdown.""" + return self._shutdown_future.done() + + def add_shutdown_callback(self, callback: Callable[[], Any]) -> None: + """Adds a callback which will be called when *better_launch* shuts down. + + Parameters + ---------- + callback : Callable + The callback to call on shutdown. + """ + self._shutdown_callbacks.append(callback) + + def shutdown(self, reason: str = None, signum: int = signal.SIGTERM) -> None: + """Ask all nodes to shutdown and terminate the internal ROS2 thread. Any subsequent calls to BetterLaunch member functions, including this one, may fail. This will typically be called when you want to terminate your launch file. + + Parameters + ---------- + reason : str, optional + A human-readable string explaining the reason for the shutdown. If not given this will be requitted with a warning. + signum : int, optional + The signal to send to child processes. + """ + if reason is None: + try: + frame = find_function_frame(self.shutdown) + self.logger.warning( + f"Shutdown was called from {frame.function}, but no reason was given" + ) + except Exception: + self.logger.warning( + "Shutdown was called without providing a reason and the calling frame could not be determined" + ) + else: + self.logger.info(f"Shutdown: {reason}") + + # Tell all nodes to shut down in opposite order + all_nodes = self.get_nodes( + include_components=False, include_launch_service=True, include_foreign=False + ) + for n in reversed(all_nodes): + try: + n.shutdown(reason, signum) + except NotImplementedError: + pass + except Exception as e: + self.logger.error( + f"Node {n.name} raised an exception during shutdown: {e}" + ) + + try: + if self._ros_adapter: + self._ros_adapter.shutdown() + self._ros_adapter = None + except Exception as e: + self.logger.error(f"RosAdapter raised an exception during shutdown: {e}") + + # If we launched extra ROS2 actions tell the launch service to shut down, too + if self._ros2_launcher is not None: + try: + self._ros2_launcher.shutdown(reason, signum) + except Exception as e: + self.logger.error( + f"ROS2 launch service raised an exception during shutdown: {e}" + ) + + try: + self._shutdown_future.set_result(None) + except Exception: + pass + + # Call any callbacks, but only once + callbacks = self._shutdown_callbacks + self._shutdown_callbacks = [] + + for cb in callbacks: + try: + cb() + except Exception as e: + self.logger.warning(f"Shutdown callback failed: {e}") + + def find( + self, + package: str = None, + filename: str = None, + subdir: str = "**", + ) -> str: + """Resolve a path to a file or package. + + If the `filename` is absolute, all other arguments will be ignored and the filename will be returned. + + When `package` names a package discoverable by ament, the corresponding ROS2 package path will be used as the base path. Instead of a package name you may also provide an absolute path, in which case it will become the base path. Any path elements after the package name will be appended to the base path. For example, to find files inside the package's shared files, specify the package as `/share`. + + Otherwise, if `package` was not specified we attempt to locate the current launch file's package by searching its directory and parent directories for a `package.xml`. If the package cannot be determined an exception is raised. + + `subdir` accepts [glob](https://docs.python.org/3/library/glob.html) patterns and can be used to resolve ambiguities, e.g. `lib/**` (anywhere inside the package's lib folder) or `share/` (directly inside the share folder). If not specified, "**" will be used (any file or directory inside the base path). + + If only `subdir` is provided but not `filename`, the first matching candidate is returned. Otherwise the discovered candidates will be searched for the given filename. + + If neither `subdir` nor `filename` is provided the base path will be returned. + + Parameters + ---------- + package : str, optional + Name of a ROS2 package to resolve. + filename : str, optional + Name of a file to look for. + subdir : str, optional + A glob pattern to locate subdirectories and files. See the `pathlib pattern language `_ for details. + + Returns + ------- + str + A resolved path. + + Raises + ------ + ValueError + If the base path could not be determined, or if a `filename` is provided but could not be found within base path. + """ + if filename and os.path.isabs(filename): + self.logger.info(f"find({package}, {filename}, {subdir}):1 -> {filename}") + return filename + + if not package: + package, _ = get_package_for_path(os.path.dirname(self.launchfile)) + self.logger.warning( + f"find: package not provided, resolved {self.launchfile} to {package}" + ) + + if package: + if os.path.isabs(package): + base_path = package + else: + parts = Path(package).parts + if len(parts) > 1: + package = parts[0] + package_path = Path(get_package_prefix(parts[0])) + base_path = str(package_path.joinpath(*parts[1:])) + else: + base_path = get_package_prefix(package) + else: + raise ValueError( + f"find({package}, {filename}, {subdir}): could not determine package" + ) + + base_path = Path(base_path).resolve() + + if not filename and subdir in (None, "", "**"): + self.logger.info(f"find({package}, {filename}, {subdir}):2 -> {base_path}") + return str(base_path) + + if not subdir: + subdir = "**" + + # In some workspaces, package files are not collected in their own package folders. + # Instead, workspace/install has global include, bin, lib, share, etc. folders where + # all the package files are placed, which is quite annoying for us. We fix this by + # requiring the filename to appear after the package name without trying to guess + # how the package files are organized. + pattern = f"**/{package}/{subdir}/" + if filename: + pattern += filename + + for candidate in base_path.glob(pattern): + candidate = candidate.resolve() + if candidate.exists(): + return str(candidate) + + raise ValueError( + f"Could not find file or directory (package={package}, filename={filename}, subdir={subdir}), searched path was {base_path}" + ) + + def load_params( + self, + package: str = None, + configfile: str = None, + subdir: str = None, + *, + qualifier: str | Node = None, + strip_qualifiers: bool = True, + ) -> dict[str, Any]: + """Load parameters from a yaml file located through [find][]. + + If the config only contains a `ros__parameters` section the entire config is returned regardless of whether a `qualifier` was passed. Otherwise, the loaded config dict is searched for a matching section. If no matching section can be found the returned dict will be empty. + + Globbing is used for matching qualifiers to paths, so the following wildcards are supported: + * `**`: matches any number of tokens, may be followed by additional tokens and a node name + * `*`: skips a single namespace token, or ignores the node's name if at the end + + Note that *better_launch* does not require you to place `ros__parameters` in your configs. If it exists it will later be used to match parameters to namespaces and nodes. For example, a config like + + ```yaml + my_node: + ros__parameters: + int_of_fury: 5 + ``` + + will be passed to a ROS2 node process as `-p my_node:int_of_fury:=5` and thus become specific to any node named `my_node`. + + .. seealso:: + + `ROS2 design doc on wildcards `_ + + Parameters + ---------- + package : str + A package to search for the config file. May be `None` (see [find][]). + configfile : str + The name of the config file to locate. + subdir : str, optional + A path fragment that the config file must be located in. + qualifier : str, optional + Used to specifiy which section of the config to return. E.g. if the yaml contains `{A: {B: C, D: E}}`, then the qualifier "A/B" will return `{A: {B: C}}`. The qualifier supports globbing patterns like `*` and `**` and will ignore `ros__parameters` keys. + strip_qualifiers : bool, optional + If True and a qualifier was passed, the qualifier will not be part of the returned dict. Set to True if you want some inner part of the params, e.g. only the params for a specific node. Set to False if you want a slice of the full params, e.g. all params for nodes in a specific namespace. + + Returns + ------- + dict[str, Any] + The key-value pairs from the config. + + Raises + ------ + ValueError + If the path cannot be resolved, of if `qualifier` is supplied and no matching section could be found. + IOError + If the config file could not be read. + """ + path = self.find(package, configfile, subdir) + + with open(path) as f: + content = f.read() + # The default yaml loader requires a dot when writing floats in scientific notation, + # whereas the json spec treats it as optional. + # This fixes #59 based on https://stackoverflow.com/a/30462009/2061551 + loader = yaml.SafeLoader + loader.add_implicit_resolver( + "tag:yaml.org,2002:float", + re.compile( + """^(?: + [-+]?(?:[0-9][0-9_]*)\\.[0-9_]*(?:[eE][-+]?[0-9]+)? + |[-+]?(?:[0-9][0-9_]*)(?:[eE][-+]?[0-9]+) + |\\.[0-9_]+(?:[eE][-+][0-9]+)? + |[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\\.[0-9_]* + |[-+]?\\.(?:inf|Inf|INF) + |\\.(?:nan|NaN|NAN))$""", + re.X, + ), + list("-+0123456789."), + ) + params = yaml.load(content, Loader=loader) + + def concatenate_branches(sub: dict, prefix: str = "") -> dict: + res = {} + for key, val in sub.items(): + path = f"{prefix}/{key}" if prefix else key + if key == "ros__parameters": + res[path] = val + elif isinstance(val, dict): + res.update(concatenate_branches(val, path)) + else: + res[path] = val + + return res + + if "ros__parameters" in content: + # Each ros__parameters block should get its own path key + params = concatenate_branches(params) + + if qualifier: + params = glob_dict(params, qualifier, strip=strip_qualifiers) + + return params + + def get_ros_message_type(self, message_string: str) -> type: + """Loads a ROS2 message type from a string representation. + + Message representations must follow the pattern `//`, where + * is the ROS2 package that defines the message. + * is the type of message, typically one of `msg`, `srv` or `action`. + * is the name of the message itself with proper capitalization. + + Parameters + ---------- + message_string : str + A message representation of the form `//`. + + Returns + ------- + type + The message class. + + Raises + ------ + ImportError + If the message type could not be imported. + """ + module_name, message_name = message_string.rsplit("/", maxsplit=1) + module = importlib.import_module(module_name.replace("/", ".")) + return getattr(module, message_name) + + def wait_for_topic( + self, + topic: str, + timeout: float = None, + ) -> bool: + """Wait for the specified topic to appear. + + Parameters + ---------- + topic : str + The full path of the topic to wait for. + timeout : float, optional + How long to wait for the topic. Wait forever if None. + + Returns + ------- + bool + True if the topic appeared within the timeout, False otherwise. + """ + now = time.time() + while True: + published = self.shared_node.get_topic_names_and_types() + for name, _ in published: + if name == topic: + return True + + if timeout is not None and time.time() > now + timeout: + return False + + time.sleep(0.1) + + def wait_for_service( + self, + service: str, + timeout: float = None, + ) -> bool: + """Wait for the specified service to appear. + + Parameters + ---------- + service : str + The full path of the service to wait for. + timeout : float, optional + How long to wait for the service. Wait forever if None. + + Returns + ------- + bool + True if the service appeared within the timeout, False otherwise. + """ + now = time.time() + while True: + published = self.shared_node.get_service_names_and_types() + for name, _ in published: + if name == service: + return True + + if timeout is not None and time.time() > now + timeout: + return False + + time.sleep(0.1) + + def qos_profile( + self, + history: Literal["keep_last", "keep_all", "default"] + | HistoryPolicy = "keep_all", + queue_size: int = 10, + reliability: Literal["reliable", "best_effort", "default"] + | ReliabilityPolicy = "reliable", + durability: Literal["volatile", "transient_local", "default"] + | DurabilityPolicy = "volatile", + deadline: float = 0, + lifespan: float = 0, + liveliness: Literal["auto", "manual", "default"] | LivelinessPolicy = "default", + alive_timeout: float = 0, + ) -> QoSProfile: + """Allows callers to quickly create a QoS profile without a million imports. Any value set to `default` will use the underlying RMW's default. + + See [Quality of Service settings](https://docs.ros.org/en/rolling/Concepts/Intermediate/About-Quality-of-Service-Settings.html) for details. + + Parameters + ---------- + history : Literal["keep_last", "keep_all", "default"] | HistoryPolicy, optional + `keep_all`: store up to N samples according to `queue_size`; `keep_all`: store as many samples as the RMW allows. + queue_size : int, optional + Number of samples to keep for `history = keep_all`. + reliability : Literal["reliable", "best_effort", "default"] | ReliabilityPolicy, optional + `reliable`: retry sending on errors; `best_effort`: never retry. + durability : Literal["volatile", "transient_local", "default"] | DurabilityPolicy, optional + `volatile`: fire and forget; `transient_local`: publisher keeps data available for late-joining subscribers. To create a latched topic configure both publisher and subscriber with `transient_local`. + deadline : float, optional + Expected maximum time between published messages. + lifespan : float, optional + How much time is allowed to pass between sending and receiving the message before it will be marked as stale. + liveliness : Literal["auto", "manual", "default"] | LivelinessPolicy, optional + `auto`: all publishers of a node are considered alive for the `alive_timeout` when any one of them fires; `manual`: publishers have to regularly tell the system that they are alive. + alive_timeout : float, optional + If a publisher doesn't tell the system it's alive for this amount of time it will be considered dead. + + Returns + ------- + QoSProfile + _description_ + """ + from rclpy.duration import Duration + + if isinstance(history, str): + history = { + "keep_last": HistoryPolicy.KEEP_LAST, + "keep_all": HistoryPolicy.KEEP_ALL, + "default": HistoryPolicy.SYSTEM_DEFAULT, + }[history] + + if isinstance(reliability, str): + reliability = { + "reliable": ReliabilityPolicy.RELIABLE, + "best_effort": ReliabilityPolicy.BEST_EFFORT, + "default": ReliabilityPolicy.SYSTEM_DEFAULT, + }[reliability] + + if isinstance(durability, str): + durability = { + "volatile": DurabilityPolicy.VOLATILE, + "transient_local": DurabilityPolicy.TRANSIENT_LOCAL, + "default": DurabilityPolicy.SYSTEM_DEFAULT, + }[durability] + + if isinstance(liveliness, str): + liveliness = { + "auto": LivelinessPolicy.AUTOMATIC, + "manual": LivelinessPolicy.MANUAL_BY_TOPIC, + "default": LivelinessPolicy.SYSTEM_DEFAULT, + }[liveliness] + + return QoSProfile( + history=history, + depth=queue_size or 10, + reliability=reliability, + durability=durability, + lifespan=Duration(seconds=deadline), + deadline=Duration(seconds=lifespan), + liveliness=liveliness, + liveliness_lease_duration=Duration(seconds=alive_timeout), + ) + + def subscriber( + self, + topic: str, + message_type: str | type, + callback: Callable[[Any], None], + qos_profile: QoSProfile | int = 10, + ) -> RosSubscriber: + """Create a ROS2 subscriber to receive messages. + + Parameters + ---------- + topic : str + The topic to listen on for messages. + message_type : str | type + The type of the messages that will be received. Strings must follow the pattern `/msg/`. + callback : Callable[[Any], Any] + A function that will be called whenever a message is received. + qos_profile : QoSProfile | int, optional + A quality of service profile that changes how the publisher handles connections and retains data. + + Returns + ------- + RosSubscriber + The subscriber object. Although not required for Jazzy and below, it is recommended to keep a reference. + """ + if isinstance(message_type, str): + message_type = self.get_ros_message_type(message_type) + + return self.shared_node.create_subscription( + message_type, + topic, + callback, + qos_profile=qos_profile, + ) + + def publisher( + self, topic: str, message_type: str | type, qos_profile: QoSProfile | int = 10 + ) -> RosPublisher: + """Create a ROS2 publisher using the [shared_node][]. + + Parameters + ---------- + topic : str + The topic to publish messages on. + message_type : str | type + The message type that will be published. Strings must follow the pattern `/msg/`. + qos_profile : QoSProfile | int, optional + A quality of service profile that changes how the publisher handles connections and retains data. + + Returns + ------- + RosPublisher + The publisher object. Although not required for Jazzy and below, it is recommended to keep a reference. + """ + if isinstance(message_type, str): + message_type = self.get_ros_message_type(message_type) + + return self.shared_node.create_publisher( + message_type, + topic, + qos_profile=qos_profile, + ) + + def publish_message( + self, + topic: str, + message_type: str | type, + message_args: dict[str, Any], + qos_profile: QoSProfile | int = 10, + *, + time_to_publish: float = 1.0, + ) -> None: + """Convenience method to publish a single message. The publisher will be destroyed once the message has been published. If you plan to publish additional messages, use [publisher][] instead and use the instance. + + Parameters + ---------- + topic : str + The topic to publish messages on. + message_type : str | type + The message type that will be published. Strings must follow the pattern `/msg/`. + message_args : dict[str, Any] + The keyword arguments from which the message will be constructed. + qos_profile : QoSProfile | int, optional + A quality of service profile that changes how the publisher handles connections and retains data. + time_to_publish: float, optional + How long to give the publisher to submit the message. Expect the message to get lost if you set this to 0. + """ + if isinstance(message_type, str): + message_type: type = self.get_ros_message_type(message_type) + + msg = message_type(**message_args) + self.logger.info(f"Publishing single message to {topic}:\n {msg}") + + pub = self.publisher(topic, message_type, qos_profile) + pub.publish(msg) + + if time_to_publish is not None and time_to_publish > 0.0: + time.sleep(time_to_publish) + + pub.destroy() + + def receive_message( + self, + topic: str, + message_type: str | type, + default: Any = _unset, + qos_profile: QoSProfile | int = 10, + *, + timeout: float = None, + ) -> Any: + from concurrent.futures import Future + + if isinstance(message_type, str): + message_type = self.get_ros_message_type(message_type) + + ret = Future() + + def cb(msg: Any) -> None: + ret.set_result(msg) + + sub = self.subscriber(topic, message_type, cb, qos_profile) + + try: + return ret.result(timeout) + except TimeoutError: + if default is not _unset: + return default + + raise + finally: + sub.destroy() + + def service( + self, + topic: str, + service_type: str | type, + callback: Callable[[Any], Any], + qos_profile: QoSProfile = None, + ) -> RosServiceProvider: + """Create a ROS2 service provider using the [shared_node][]. + + Parameters + ---------- + topic : str + The topic the service will live on. + service_type : str | type + The service's message type. Strings must follow the pattern `/srv/`. + callback : Callable[[Any], Any] + The function that will handle any requests to the service. The type of the request will be of type `service_type.Request`. + qos_profile : QoSProfile, optional + A quality of service profile that changes how the service handles connections. + + Returns + ------- + RosServiceProvider + The service object. Although not required for Jazzy and below, it is recommended to keep a reference. + """ + if isinstance(service_type, str): + service_type = self.get_ros_message_type(service_type) + + if not qos_profile: + qos_profile = qos_profile_services_default + + return self.shared_node.create_service( + service_type, + topic, + callback, + qos_profile=qos_profile, + ) + + def service_client( + self, + topic: str, + service_type: str | type, + timeout: float = 5.0, + qos_profile: QoSProfile = None, + ) -> RosServiceClient: + """Create a ROS2 service client that can be used to call a service. + + Parameters + ---------- + topic : str + The service topic to post requests on. + service_type : str | type + The service's message type. Strings must follow the pattern `/srv/`. + timeout : float, optional + Time to wait for the service to become available. Ignored if <= 0. + qos_profile : QoSProfile, optional + A quality of service profile that changes how the service handles connections. + + Returns + ------- + RosServiceClient + The client object. Although not required for Jazzy and below, it is recommended to keep a reference. + + Raises + ------ + TimeoutError + If the service did not become available within the specified timeout. + """ + if isinstance(service_type, str): + service_type = self.get_ros_message_type(service_type) + + if not qos_profile: + qos_profile = qos_profile_services_default + + client = self.shared_node.create_client( + service_type, topic, qos_profile=qos_profile + ) + if timeout > 0.0: + if not client.wait_for_service(timeout): + raise TimeoutError( + f"Service client timed out ({topic}, {service_type})" + ) + return client + + def call_service( + self, + topic: str, + service_type: str | type, + request_args: dict[str, Any] | Any = None, + *, + timeout: float = 5.0, + qos_profile: QoSProfile = None, + call_async: bool = False, + ) -> Any: + """Makes a single service request and returns the result. The client is destroyed once the request has been handled. If you plan to make additional requests, use [service_client][] instead. + + Parameters + ---------- + topic : str + The service topic to post requests on. + service_type : str | type + The service's message type. Strings must follow the pattern `/srv/`. + request_args : dict[str, Any] | Any + The keyword arguments from which the request message will be constructed. May also be an instance of `service_type.Request`. + timeout : float, optional + Time to wait for the service to become available. Ignored if <= 0. + qos_profile : QoSProfile, optional + A quality of service profile that changes how the service handles connections. + call_async : bool, optional + If True, make the service call async and return a `rclpy.task.Future` instead. + + Returns + ------- + Any + A `rclpy.task.Future` if `call_async` is True, otherwise the result of the service call of type `service_type.Request`. + + Raises + ------ + TimeoutError + If the service did not become available within the specified timeout. + """ + if isinstance(service_type, str): + service_type = self.get_ros_message_type(service_type) + + if isinstance(request_args, service_type.Request): + req = request_args + elif isinstance(request_args, dict): + req = service_type.Request(**request_args) + else: + req = service_type.Request() + + self.logger.info(f"Calling service {topic}:\n {req}") + srv = self.service_client(topic, service_type, timeout, qos_profile) + + if call_async: + res = srv.call_async(req) + res.add_done_callback(lambda f: srv.destroy()) + else: + res = srv.call(req) + + return res + + def action_server( + self, + topic: str, + action_type: str | type, + callback: Callable[[Any], Any], + qos_profile: QoSProfile = None, + ) -> "RosActionServer": + """Create a ROS2 action server using the [shared_node][BetterLaunch.shared_node]. + + Parameters + ---------- + topic : str + The topic namespace to provide the action interface on. + action_type : str | type + The type of the actions to be handled. Strings must follow the pattern `/action/`. + callback : Callable[[Any], Any] + A function that will handle incoming action requests. The type of the requests will be of type `rclpy.action.server.ServerGoalHandle` and contain an `action_type.Goal`. + qos_profile : QoSProfile, optional + A quality of service profile that changes how the action server handles connections and retains data. + + Returns + ------- + RosActionServer + The action server object. Although not required for Jazzy and below, it is recommended to keep a reference. + """ + from rclpy.action import ActionServer + + if isinstance(action_type, str): + action_type = self.get_ros_message_type(action_type) + + if not qos_profile: + qos_profile = qos_profile_services_default + + return ActionServer( + self.shared_node, + action_type, + topic, + callback, + goal_service_qos_profile=qos_profile, + result_service_qos_profile=qos_profile, + cancel_service_qos_profile=qos_profile, + ) + + def action_client( + self, + topic: str, + action_type: str | type, + timeout: float = 5.0, + qos_profile: QoSProfile = None, + ) -> "RosActionClient": + """Create a ROS2 action client to execute long-running actions. + + Parameters + ---------- + topic : str + The topic namespace on which the action interface is provided. + action_type : str | type + The type of actions the action server handles. Strings must follow the pattern `/action/`. + timeout : float, optional + Time to wait for the action server to become available. Ignored if <= 0. + qos_profile : QoSProfile, optional + A quality of service profile that changes how the action server handles connections and retains data. + + Returns + ------- + RosActionClient + The action client object. Although not required for Jazzy and below, it is recommended to keep a reference. + + Raises + ------ + TimeoutError + If the action server did not become available within the specified timeout. + """ + # Lazy import, these add a lot of overhead + from rclpy.action import ActionClient + + if isinstance(action_type, str): + action_type = self.get_ros_message_type(action_type) + + if not qos_profile: + qos_profile = qos_profile_services_default + + client = ActionClient( + self.shared_node, + action_type, + topic, + goal_service_qos_profile=qos_profile, + result_service_qos_profile=qos_profile, + cancel_service_qos_profile=qos_profile, + ) + + if timeout > 0.0: + if not client.wait_for_server(timeout): + raise TimeoutError(f"Action client timed out ({topic}, {action_type})") + return client + + @contextmanager + def group( + self, namespace: str, use_sim_time: bool = None + ) -> Generator[Group, None, None]: + """Groups are used to bundle nodes into namespaces. While they influence the nodes' topics + and service name, they have no runtime functionality. + + Groups are intended to be used as context objects and can be nested. Note that starting a + new root branch (i.e. a group starting with "/") is valid and will change subsequent groups + for the duration of the context window. + + .. code:: python + + bl = BetterLaunch() + with bl.group("outer"): + # Unless included in another group, "outer" will be attached to "/" + with bl.group("inner/sanctum"): + # Regular nesting, nodes will live within "/outer/inner/sanctum" + bl.node(...) + with bl.group("/evil/tower"): + # New root branch, nodes will live within "/evil/tower" + bl.node(...) + # Root branch exited, nodes will live within "/outer" once again + bl.node(...) + + .. seealso:: + + * [group_root][] + * [group_tip][] + + Parameters + ---------- + namespace : str + The group's namespace. + use_sim_time : bool, optional + Decide whether nodes within this group should use simulated time. Leave as None to use the parent group's use_sim_time setting. The root group defaults to False unless the corresponding environment variable or CLI switch have been modified. + + Yields + ------ + Generator[Group, None, None] + Places the group on the group stack and yields it. Exiting the context will pop the group from the group stack. + + Raises + ------ + RuntimeError + If the group is created within a compose context. + """ + if self._composition_node: + raise ValueError("Cannot create a group inside a compose context") + + # It's possible to start a new root branch, especially when including launch files. Once + # we exit that branch the previous stack should be restored + old_stack = self._group_stack[:] + if namespace.startswith("/"): + self._group_stack = [self._group_root] + + tip = self.group_tip + + if use_sim_time is None: + use_sim_time = tip.use_sim_time + + for token in namespace.strip("/").split("/"): + if token in tip.children: + branch = tip.children[token] + else: + branch = Group(tip, token, use_sim_time) + tip.add_child(branch) + + self._group_stack.append(branch) + tip = branch + + try: + yield tip + finally: + # Restore the old stack. + # Since it is possible to start a new root branch or open up multiple/namespaces/at/ + # once we replace the entire stack rather than only removing elements from the end + self._group_stack = old_stack + + # There's no real need to have this as a member of BetterLaunch, but it's kind of expected to + # be there. By making it a class method it can still be used without a BL instance. + @classmethod + def exec(cls, cmd: str | list[str]) -> str: + """Run the specified command, await its termination and return its output. Bare commands are resolved using `shutil.which`. + + For long-lived commands that you don't want to wait for, consider using [BetterLaunch.process][] instead. + + Parameters + ---------- + cmd : str | list[str] + The command to run. If a string is passed it will be split on spaces to separate the command from its arguments. Pass a list instead to have more control over which arguments to treat as a single argument. + + Returns + ------- + str + The output of the command without the trailing newline. + + Raises + ------ + subprocess.CalledProcessError + If the command had a non-zero exit code. See the raised error's `returncode` and `output` attributes for details. + """ + if isinstance(cmd, str): + cmd = shlex.split(cmd) + + executable = cmd[0] + if not os.path.isabs(executable) and os.sep not in executable: + cmd[0] = shutil.which(executable) + + bl = BetterLaunch.instance() + if bl: + logger = bl.logger + else: + logger = logging.getLogger("Exec") + + # In case this is a ROS2 process we want it to use a different format + env = os.environ.copy() + env["RCUTILS_CONSOLE_OUTPUT_FORMAT"] = " [EXEC] {message}" + + logger.info(f"Executing command {cmd}") + ret = ( + subprocess.check_output(cmd, env=env, stderr=subprocess.STDOUT) + .decode() + .rstrip("\n") + ) + logger.debug(ret) + + return ret + + def process( + self, + cmd: str | list[str], + name: str = None, + *, + env: dict[str, str] = None, + isolate_env: bool = False, + output: LogSink | Iterable[LogSink] | Iterable[str] | str = LogSink.SCREEN, + anonymous: bool = False, + on_exit: Callable = None, + max_respawns: int = 0, + respawn_delay: float = 0.0, + use_shell: bool = False, + autostart_process: bool = True, + ) -> Node: + """Starts an arbitrary process and wraps it in a Node object. + + This method for starting long-running non-ROS processes, similar to ROS2's `ExecuteProcess`. Bare command names are resolved using `shutil.which`. + + If you instead want to wait for the process to return (and retrieve its output), consider using [BetterLaunch.exec][] instead. + + Parameters + ---------- + cmd : str | list[str] + The command to execute. If a string is provided, it will be split using `shlex.split`. + name : str, optional + The name of the process. If not provided, it will be derived from the command. + env : dict[str, str], optional + Additional environment variables to set for the process. The process will merge these with the environment variables of the better_launch host process unless `isolate_env` is True. + isolate_env : bool, optional + If True, the process' env will not be inherited from the parent process and only those passed via `env` will be used. + output : LogSink | Iterable[LogSink] | Iterable[str] | str, optional + Determines if and where this process' output should be directed. Defaults to `LogSink.SCREEN`. + anonymous : bool, optional + If True, the process name will be appended with a unique suffix to avoid name conflicts. + on_exit : Callable, optional + A function to call when the process terminates (after any possible respawns). + max_respawns : int, optional + How often to restart the process if it terminates. + respawn_delay : float, optional + How long to wait before restarting the process after it terminates. + use_shell : bool, optional + If True, invoke the executable via the system shell. + autostart_process : bool, optional + If True, start the process before returning from this function. + + Returns + ------- + Node + The node object wrapping the process. + + Raises + ------ + ValueError + If the command is empty. + FileNotFoundError + If the executable cannot be found. + """ + if not cmd: + raise ValueError("Command cannot be empty") + + if isinstance(cmd, str): + cmd = shlex.split(cmd) + + executable = cmd[0] + cmd_args = cmd[1:] + + if name is None: + name = os.path.basename(executable) + + # Resolve executable to absolute path if it's not already one + if not os.path.isabs(executable) and os.sep not in executable: + resolved_executable = shutil.which(executable) + if resolved_executable is None: + raise FileNotFoundError(f"Executable '{executable}' not found in PATH") + executable = resolved_executable + + return self.node( + package="", + executable=executable, + name=name, + cmd_args=cmd_args, + raw=True, + env=env, + isolate_env=isolate_env, + output=output, + anonymous=anonymous, + on_exit=on_exit, + max_respawns=max_respawns, + respawn_delay=respawn_delay, + use_shell=use_shell, + autostart_process=autostart_process, + ) + + def node( + self, + package: str, + executable: str, + name: str = None, + *, + remaps: dict[str, str] = None, + params: str | dict[str, Any] = None, + param_files: str | list[str] = None, + use_sim_time: bool = None, + drop_param_qualifiers: bool = False, + cmd_args: list[str] = None, + prefix_args: list[str] = None, + env: dict[str, str] = None, + isolate_env: bool = False, + log_level: int = logging.INFO, + output: LogSink | Iterable[LogSink] | Iterable[str] | str = LogSink.SCREEN, + anonymous: bool = False, + hidden: bool = False, + on_exit: Callable = None, + max_respawns: int = 0, + respawn_delay: float = 0.0, + use_shell: bool = False, + autostart_process: bool = True, + ros_waittime: float = 3.0, + lifecycle_waittime: float = 0.01, + lifecycle_target: LifecycleStage | str = LifecycleStage.ACTIVE, + raw: bool = False, + remap_qualifier: str = None, + qualify_all_remaps: bool = False, + ) -> Node: + """Create a new ROS2 node process. The bread and butter of every ROS setup! + + Please note that by default better_launch will generate an anonymous node name if no node name was specified. This helps to avoid multiple nodes with the same name, which in ROS2 is both possible and problematic. If you really don't want to specify a node name, pass an empty string instead. + + This method also handles lifecycle nodes (they REALLY should have a common interface). Note that especially for lifecycle nodes you probably want `autostart_process == True`, otherwise there lifecycle management will not exist. With `autostart_process == True`, a lifecycle node will automatically advance to `lifecycle_target` once it is up. Otherwise you can also call [Node.start][better_launch.elements.abstract_node.AbstractNode.start] later. + + The `ROS2 documentation `_ can provide some additional information regarding `params`, `remaps`, and so on. + + Parameters + ---------- + package : str + The package providing the node. + executable : str + The executable that should be run. + name : str, optional + The name you want the node to be known as. If `None`, a name will be derived from `package` and `executable` and `anonymous` will be set to True. Pass an empty string instead if you really want to use the node's default name - just know that you'll make a cute kitten really sad. + remaps : dict[str, str], optional + Tells the node to replace any topics it wants to interact with according to the provided dict. + params : str | dict[str, Any], optional + Any ROS parameters you want to pass to the node. These are the args you would typically have to declare in your launch file. A string will be interpreted as a path to a yaml file which will be lazy loaded using [BetterLaunch.load_params][]. + param_files : str | list[str], optional + Paths to parameter files that will be passed to the node as is. If both param_files and params are present, param_files will be passed first (same order), followed by the params. + use_sim_time : bool, optional + If set decides whether the node should use simulated time. Otherwise uses the setting from the current [BetterLaunch.group_tip][]. + drop_param_qualifiers : bool, optional + If True, any namespace/node qualifiers in the passed params are ignored. + cmd_args : list[str], optional + Additional command line arguments to pass to the node. + prefix_args : list[str], optional + Arguments to prepend to the resolved run command, e.g. for executing the node through gdb. + env : dict[str, str], optional + Additional environment variables to set for the node's process. The node process will merge these with the environment variables of the better_launch host process unless `isolate_env` is True. + isolate_env : bool, optional + If True, the node process' env will not be inherited from the parent process and only those passed via `env` will be used. Be aware that this can result in many common things to not work anymore since e.g. keys like *PATH* will be missing. + log_level : int, optional + The minimum severity a logged message from this node must have in order to be published. This will be added to the cmd_args unless it is None. + output : LogSink | Iterable[LogSink] | Iterable[str] | str, optional + Determines if and where this node's output should be directed. Common choices are `screen` to print to terminal, `log` to write to a common log file, `own_log` to write to a node-specific log file, and `none` to not write any output anywhere. See [configure_logger][utils.better_logging.configure_logger] for details. + anonymous : bool, optional + If True, the node name will be appended with a unique suffix to avoid name conflicts. + hidden : bool, optional + If True, the node name will be prepended with a "_", hiding it from common listings. + on_exit : Callable, optional + A function to call when the node's process terminates (after any possible respawns). + max_respawns : int, optional + How often to restart the node process if it terminates. + respawn_delay : float, optional + How long to wait before restarting the node process after it terminates. + use_shell : bool, optional + If True, invoke the node executable via the system shell. While this gives access to the shell's builtins, this has the downside of running the node inside a "mystery program" which is platform and user dependent. Generally not advised. + autostart_process : bool, optional + If True, start the node process before returning from this function. + ros_waittime : float, optional + How long to wait for the node to register with ROS. This should cover the time between the process starting and the node initializing itself. Set negative to wait indefinitely. Set to None to avoid the check entirely. Will do nothing if `autostart_process` is False. + lifecycle_waittime : float, optional + How long to wait for the node's lifecycle management to come up. This should cover the time between the node initializing itself (see `ros_waittime`) and creating its additional topics and services. While neglible on modern computers, slower devices and embedded systems may experience a noticable delay here. Set negative to wait indefinitely. Set to None to avoid the check entirely. Will do nothing if `autostart_process` is False. + lifecycle_target : LifecycleStage | str, optional + The lifecycle stage to bring the node into after starting. Has no effect if `autostart_process` is False or if the node does not appear to be a lifecycle node after waiting `ros_waittime + lifecycle_waittime`. + raw : bool, optional + If True, don't treat the executable as a ROS2 node and avoid passing it any command line arguments except those specified. + remap_qualifier : str, optional + Additional qualifier that will precede the node's `__ns` and `__name` remap rules. Should be the original name of the node (i.e. whatever its default name is) and can be qualified with a namespace. Useful to prevent multiple nodes with the same name when a process can have more than one node (e.g. `controller_manager`). See [this ROS2 design doc](https://design.ros2.org/articles/static_remapping.html#how-the-syntax-works) for more information. + qualify_all_remaps : bool, optional + If True, apply the `remap_qualifier` to all remaps that are not already qualified. + + Returns + ------- + Node + The node object wrapping the node process. + + Raises + ------ + RuntimeError + If you try to add a node withing a [compose][] context. + """ + if self._composition_node: + raise RuntimeError("Cannot add nodes inside a composition node") + + if name is None: + name = f"{package}_{executable}" + if not anonymous: + self.logger.warning( + f"Name of node {package}/{executable} not set, will use anonymous name" + ) + anonymous = True + + if anonymous: + name = self.get_unique_name(name) + + if hidden and not name.startswith("_"): + name = "_" + name + + group = self.group_tip + namespace = group.assemble_namespace() + + if use_sim_time is None: + use_sim_time = group.use_sim_time + + node = Node( + package, + executable, + name, + namespace, + remaps=remaps, + params=params, + param_files=param_files, + use_sim_time=use_sim_time, + drop_param_qualifiers=drop_param_qualifiers, + cmd_args=cmd_args, + prefix_args=prefix_args, + env=env, + isolate_env=isolate_env, + log_level=log_level, + output=output, + on_exit=on_exit, + max_respawns=max_respawns, + respawn_delay=respawn_delay, + use_shell=use_shell, + raw=raw, + remap_qualifier=remap_qualifier, + qualify_all_remaps=qualify_all_remaps, + ) + + group.add_node(node) + if autostart_process: + node.start() + + if ros_waittime is not None and node.is_ros2_connected(ros_waittime): + if ( + lifecycle_target not in (None, LifecycleStage.PRISTINE) + and str(lifecycle_target).upper() != "PRISTINE" + and lifecycle_waittime is not None + and node.is_lifecycle_node(lifecycle_waittime) + ): + node.lifecycle.transition(lifecycle_target) + + return node + + @contextmanager + def compose( + self, + name: str = None, + language: str = "cpp", + variant: Literal["normal", "multithreading", "isolated"] = "normal", + *, + reuse_existing: bool = True, + component_remaps: dict[str, str] = None, + anonymous: bool = False, + hidden: bool = False, + use_sim_time: bool = None, + autostart_process: bool = True, + ros_waittime: float = 3.0, + output: LogSink | Iterable[LogSink] | Iterable[str] | str = LogSink.SCREEN, + ) -> Generator[Composer, None, None]: + """Creates a composer node which can be used to load [Component][]s. Components can be instantiated directly, or preferably via [component][]. Only components can reside within a composer. + + Existing composers can be reused even if they have been created outside of *better_launch*. See [Composer][] for further details. + + This method should be used as a context, e.g. + + .. code:: python + + bl = BetterLaunch() + with bl.compose("my-composer"): + bl.component("my_package", "mystuff:TheComponentOfDreams", "normal-component") + + Note that new nodes created by a component are using the composer's remaps. This for example applies to transform listeners (but not transform publishers). See the `related issue `_ in rclcpp. + + Parameters + ---------- + name : str, optional + The name you want the composer to be known as. `anonymous` will be set to True if no name is provided. Pass an empty string if you really want to use the composer node's default name. + language : str, optional + The implementation of the standard composer you want to use. Ignored if `reuse_existing` is True and a matching node is found. + variant : Literal["normal", "multithreading", "isolated"], optional + ROS2 provides special composers for components that need multithreading or should be isolated from the rest. Ignored if `reuse_existing` is True and a matching node is found. + reuse_existing : bool, optional + If True and a node matching the current namespace and provided name is found, it will be used instead of creating a new node. This will even work for composers not created through better_launch, although in that case it won't be possible to stop them. + component_remaps : dict[str, str], optional + Any remaps you want to apply to all *components* loaded into this composer. + anonymous : bool, optional + If True, the composer name will be appended with a unique suffix to avoid name conflicts. `reuse_existing` will be set to False in this case. + hidden : bool, optional + If True, the composer name will be prepended with a "_", hiding it from common listings. + use_sim_time : bool, optional + If set decides whether the node should use simulated time. Otherwise uses the setting from the current [BetterLaunch.group_tip][]. Only effective when a new composer is created. By ROS2 design, all componentens added to this composer will inherit this setting. + autostart_process : bool, optional + If True, start the composer process before returning from this function. Note that setting this to False for a composer will make it unusable as a context object, since you won't be able to load any components. + ros_waittime : float, optional + How long to wait for the composer to register with ROS. This should cover the time between the process starting and the composer initializing itself. Set negative to wait indefinitely. Will do nothing if `autostart_process` is False. + output : LogSink | Iterable[LogSink] | Iterable[str] | str, optional + Determines if and where this node's output should be directed. Common choices are `screen` to print to terminal, `log` to write to a common log file, `own_log` to write to a node-specific log file, and `none` to not write any output anywhere. See [configure_logger][utils.better_logging.configure_logger] for details. + + Yields + ------ + Generator[Composer, None, None] + Sets the composition flag and yields the composer. + + Raises + ------ + RuntimeError + If you try to create a composer within a [compose][] context. + """ + if self._composition_node is not None: + raise RuntimeError("Cannot nest composition nodes") + + if name is None: + name = "composer" + if not anonymous: + self.logger.warning("Name of composer not set, will use anonymous name") + anonymous = True + + if anonymous: + name = self.get_unique_name(name) + reuse_existing = False + + if hidden and not name.startswith("_"): + name = "_" + name + + group = self.group_tip + namespace = group.assemble_namespace() + + node_ref = None + + if reuse_existing: + # Try to find an already running node we can reuse + ns = namespace.strip("/") + fullname = "/" + ns + ("/" if ns else "") + name + + # Check if it's a node we've created + node_ref = self.query_node(fullname, include_components=False) + + if not node_ref: + # Otherwise see if there's a foreign node matching the full name + living_nodes = set( + _ns + ("" if _ns.endswith("/") else "/") + _name + for _name, _ns in self.shared_node.get_node_names_and_namespaces() + ) + + if fullname in living_nodes: + node_processes = find_process_for_node(namespace, name) + if not node_processes: + self.logger.error( + "Could not identify process for node %s/%s, creating new composer", + namespace, + name, + ) + elif len(node_processes) > 1: + self.logger.warning( + "Found multiple node processes matching %s/%s, using most recent", + namespace, + name, + ) + node_ref = ForeignNode.wrap_process(node_processes[-1]) + else: + node_ref = ForeignNode.wrap_process(node_processes[0]) + + if node_ref and not Composer.is_composer(node_ref, timeout=ros_waittime): + # We will still reuse it but raise some awareness + self.logger.warning( + f"Reused composer node {node_ref.fullname} does not provide the expected services (yet)" + ) + + if not node_ref: + # Node doesn't exist yet or it should not be reused, create a new composer + package = f"rcl{language}_components" + + # The actual implementation of the composer + if variant == "normal": + executable = "component_container" + elif variant == "multithreading": + executable = "component_container_mt" + elif variant == "isolated": + executable = "component_container_isolated" + else: + raise ValueError(f"Unknown container mode '{variant}") + + if use_sim_time is None: + use_sim_time = group.use_sim_time + + node_ref = Node( + package, + executable, + name, + namespace, + remaps=component_remaps, + use_sim_time=use_sim_time, + output=output, + ) + + if isinstance(node_ref, Composer): + comp = node_ref + else: + comp = Composer(node_ref, component_remaps=component_remaps, output=output) + + try: + group.add_node(comp) + + if autostart_process: + comp.start(service_timeout=ros_waittime) + + self._composition_node = comp + yield comp + finally: + self._composition_node = None + + def component( + self, + package: str, + plugin: str, + name: str = None, + *, + remaps: dict[str, str] = None, + params: str | dict[str, Any] = None, + anonymous: bool = False, + hidden: bool = False, + use_intra_process_comms: bool = True, + ros_waittime: float = 3.0, + lifecycle_waittime: float = 0.01, + lifecycle_target: LifecycleStage | str = LifecycleStage.ACTIVE, + output: LogSink | Iterable[LogSink] | Iterable[str] | str = LogSink.SCREEN, + **extra_composer_args: dict[str, Any], + ) -> Component: + """Create a component and load it into an existing [compose][] context. + + If you instead want to load components without a `compose` context, you should instantiate [Component][] objects directly, then load them via [Component.start][] or [Composer.load_component][]. See the examples for more details. + + Parameters + ---------- + package : str + The package providing the component implementation. + plugin : str + The name the component is registered as, typically of the form `::`. + name : str, optional + The name the instantiated component should be known as. If `None`, a name will be derived from `package` and `plugin`, and `anonymous` will be set to True. Pass an empty string instead if you really want to use the node's default name - just know that you'll make a cute kitten really sad. + remaps : dict[str, str], optional + Tells the node to replace any topics it wants to interact with according to the provided dict. + anonymous : bool, optional + If True, the composer name will be appended with a unique suffix to avoid name conflicts. + hidden : bool, optional + If True, the composer name will be prepended with a "_", hiding it from common listings. + params : str | dict[str, Any], optional + Any ROS parameters you want to pass to the component. These are the args you would typically have to declare in your launch file. A string will be interpreted as a path to a yaml file which will be lazy loaded using [BetterLaunch.load_params][]. + use_intra_process_comms : bool, optional + If True, ask the composer node to enable intra-process communication, i.e. share memory between components when passing messages instead of serializing and deserializing. + ros_waittime : float, optional + How long to wait for the component to register with ROS. This should cover the time between the process starting and the component initializing itself. Set negative to wait indefinitely. Set to None to avoid the check entirely. Will do nothing if `autostart_process` is False. + lifecycle_waittime : float, optional + How long to wait for the component's lifecycle management to come up. This should cover the time between the component initializing itself (see `ros_waittime`) and creating its additional topics and services. While neglible on modern computers, slower devices and embedded systems may experience a noticable delay here. Set negative to wait indefinitely. Set to None to avoid the check entirely. Will do nothing if `autostart_process` is False. + lifecycle_target : LifecycleStage | str, optional + The lifecycle stage to bring the component into after starting. Has no effect if `autostart_process` is False or if the component does not appear to be a lifecycle component after waiting `ros_waittime + lifecycle_waittime`. + output : LogSink | Iterable[LogSink] | Iterable[str] | str, optional + Determines if and where this node's output should be directed. Common choices are `screen` to print to terminal, `log` to write to a common log file, `own_log` to write to a node-specific log file, and `none` to not write any output anywhere. See [configure_logger][utils.better_logging.configure_logger] for details. + + Returns + ------- + Component + The component that has been loaded into the current [compose][] context. + + Raises + ------ + RuntimeError + If this is called outside a [compose][] context. + """ + if self._composition_node is None: + raise RuntimeError("Cannot add component outside a compose() node") + + if not name: + name = f"{package}_{plugin.replace('::', '_')}" + if not anonymous: + self.logger.warning( + f"Name of {package}::{plugin} not set, will use anonymous name" + ) + anonymous = True + + if anonymous: + name = self.get_unique_name(name) + + if hidden and not name.startswith("_"): + name = "_" + name + + group = self.group_tip + namespace = group.assemble_namespace() + + comp = Component( + self._composition_node, + package, + plugin, + name, + namespace, + remaps=remaps, + params=params, + output=output, + ) + + # Equivalent to self._composition_node.load_component(comp) + comp.start( + use_intra_process_comms=use_intra_process_comms, + **extra_composer_args, + ) + + if ros_waittime is not None and comp.is_ros2_connected(ros_waittime): + if ( + lifecycle_target not in (None, LifecycleStage.PRISTINE) + and str(lifecycle_target).upper() != "PRISTINE" + and lifecycle_waittime is not None + and comp.is_lifecycle_node(lifecycle_waittime) + ): + comp.lifecycle.transition(lifecycle_target) + + return comp + + @classmethod + def is_included(cls) -> bool: + """Check if this is run from an included launchfile. + + NOTE: this will only work when (indirectly) invoked from [launch_this][]. + + More specifically, this checks if a [BetterLaunch][] instance has been stored in the calling frame's globals, which happens on the first instantiation. This mechanism is an implementation detail and should not be relied on. + + Returns + ------- + bool + True if a launch_this function has already run. + + Raises + ------ + ValueError + When launch_this is not part of the current stack frame. + """ + bl = BetterLaunch.instance() + + if not bl: + return False + + from .wrapper import _expose_ros2_launch_function + + # Check various ways how we could have been included + # TODO verify this works + for func in (cls.include, _expose_ros2_launch_function): + try: + find_calling_frame(func) + return True + except ValueError: + pass + + return False + + def include( + self, + package: str, + launchfile: str, + subdir: str = None, + *, + pass_launch_func_args: bool = None, + **kwargs, + ) -> None: + """Include another launch file, resolving its path using [find][]. + + The file is first read into memory and checked. If it seems to be a *better_launch* launch file, it is executed immediately (using [exec]). The BetterLaunch instance and global context will be shared. Any arguments to [launch_this][] in the included launch file will be ignored. + + If the file does not appear to be a *better_launch* launch file, it is assumed to be a regular ROS2 launch file. In this case a `launch.actions.IncludeLaunchDescription` instance is created and passed to [ros2_actions][]. + + Parameters + ---------- + package : str + The package containing the specified launch file. May be `None` (see [find][]). + launchfile : str + The name of a launch file to execute. + subdir : str, optional + A path fragment the launch file must be located in. + pass_launch_func_args : bool, optional + If True, all `launch_args` will be passed to the included launch file. Additional launch arguments can also be provided via `kwargs`. + + Raises + ------ + ValueError + If the passed in `search_args` cannot be handled. + """ + file_path = self.find(package, launchfile, subdir) + + # Pass additional arguments, e.g. launch args + include_args = {} + if pass_launch_func_args or (pass_launch_func_args is None and self.pass_launch_func_default): + include_args.update(self.launch_args) + include_args.update(**kwargs) + + try: + if file_path.lower().endswith(".toml"): + # TOML launchfile + from better_launch.declarative import _execute_toml + + _execute_toml(file_path, **include_args) + elif file_path.lower().endswith(".py") and find_launchthis_function( + file_path + ): + # Python better_launch launchfile + # Read the code, compile it and insert ourselves before running it + with open(file_path) as f: + source = f.read() + + code = compile(source, launchfile, "exec") + + # Make sure the included launch file reuses our BetterLaunch instance + global_args = dict(globals()) + global_args[_bl_singleton_instance] = self + global_args[_bl_include_args] = include_args + + # Since we're running an entire module locals won't have any effect + exec(code, global_args) + else: + # Assume it's a ROS2 launch file (py, xml, yaml) + self._include_ros2_launchfile(file_path, **include_args) + except Exception as e: + self.logger.error(f"Launch include '{package}/{launchfile}' failed: {e}") + raise + + def _include_ros2_launchfile(self, file_path: str, **kwargs) -> None: + # Delegate to ros2 launch service + from launch.actions import IncludeLaunchDescription + from launch.launch_description_sources import ( + AnyLaunchDescriptionSource, + ) + + # See https://github.com/ros2/launch_ros/blob/rolling/ros2launch/ros2launch/api/api.py#L175 + ros2_include = IncludeLaunchDescription( + AnyLaunchDescriptionSource(file_path), + launch_arguments=[ + # ROS2 can handle only tuples of strings and strings/substitutions here... + (key, self._value_to_yaml(val) if val is not None else "") + for key, val in kwargs.items() + ], + ) + self.ros2_actions(ros2_include) + + def _value_to_yaml(self, val: Any) -> str | Any: + """Convert a value to a YAML string suitable for ROS2 launch arguments. + + Optimized for performance on embedded platforms (Jetson Orin Nano). + Uses direct type dispatch for primitives to avoid json.dumps overhead. + + Parameters + ---------- + val: Any + The value to convert. + + Returns + ------- + str | Any + A YAML-formatted string for primitives/containers, or the object itself + if it is a ROS2 Substitution. + + Raises + ------ + ValueError + If the passed in value could not be serialized. + """ + if val is None: + return "" + + # Fast path for primitives using direct type checking + # This avoids the overhead of the JSON encoder for the 90% case + t = type(val) + if t is bool: + return "true" if val else "false" + elif t is int: + return str(val) + elif t is float: + if val != val: # NaN + return ".NaN" + if val == float("inf"): + return ".inf" + if val == float("-inf"): + return "-.inf" + return str(val) + elif t is str: + return val + + # Check for ROS2 Substitution objects + # We can probably get away without importing from ROS2 to keep this function more general. + if hasattr(val, "perform") or hasattr(val, "describe"): + return val + + # Fallback to JSON serialization for containers (list, dict) + # JSON is valid YAML and safer/cleaner than yaml.dump for these + import json + + try: + return json.dumps(val) + except TypeError as e: + # Fallback for non-serializable types (e.g. custom objects) + # We could try str(), but it might not be valid YAML + raise ValueError( + f"Failed to serialize launch argument '{val}' ({type(val).__name__}): {e}" + ) from e + + def ros2_launch_service( + self, + name: str = "LaunchService", + launchservice_args: list[str] = None, + output: LogSink | Iterable[LogSink] | Iterable[str] | str = LogSink.SCREEN, + start_immediately: bool = True, + ) -> Ros2LaunchWrapper: + """Create or retrieve a manager object that can be used for queueing ROS2 launch actions. + + Usually, calling [ros2_actions][] is more convenient for queueing actions. However, calling this *first* allows to prevent starting the underlying `launch.LaunchService` immediately, giving more control over when the actions are executed. + + Since the `LaunchService` insists on running on the main thread it will be started as a sub process. + + Note that only one instance of the ROS2 wrapper should ever exist. Calling this method after it has been created will return the already existing instance instead. Any passed arguments will be silently discarded. + + Parameters + ---------- + name : str, optional + The name used to identify the process and its logger. + launchservice_args : list[str], optional + Additional launch arguments to pass to the ROS2 launch service. These will end up in `launch.LaunchContext.argv`. + output : LogSink | Iterable[LogSink] | Iterable[str] | str, optional + How log output from the launch service should be handled. This will also include the output from all nodes launched by this launch service. Common choices are `screen` to print to terminal, `log` to write to a common log file, `own_log` to write to a node-specific log file, and `none` to not write any output anywhere. See [configure_logger][utils.better_logging.configure_logger] for details. + start_immediately : bool, optional + If True, the ROS2 launch service process is started immediately. + + Returns + ------- + Ros2LaunchWrapper + The wrapper hosting the ROS2 launch service process. + """ + if not self._ros2_launcher: + self._ros2_launcher = Ros2LaunchWrapper( + name=name, + launchservice_args=launchservice_args, + output=output, + ) + + if start_immediately and not self._ros2_launcher.is_running: + self._ros2_launcher.start() + + return self._ros2_launcher + + def ros2_actions(self, *ros2_actions) -> Ros2LaunchWrapper: + """Submit additional ROS2 launch actions for execution. + + If no `launch.LaunchService` exists yet it will be created and started immediately. + """ + self.ros2_launch_service().queue_ros2_actions(*ros2_actions) + return self._ros2_launcher + + def run_later(self, delay: float, callback: Callable, *args, **kwargs) -> Future: + """Convenience method for running a callback with a delay. The callback will be called on a separte thread. + + This mainly exists to cover the use case where you want to interact with ROS from an `rclpy.Timer`. A synchronous call from within a timer (e.g. a service call like [Node.set_live_params][]) will block ROS' background event loop, preventing publishers, subscribers, services, etc. from doing their work. It will also prevent a clean shutdown as ROS usually waits for the event queue to become empty. + + When executing a long running task this way it is a good idea to check [is_shutdown][] in between iterations. + + Parameters + ---------- + delay : float + How long to wait in seconds before calling the callback. + callback : Callable + The function to call after the timeout. Will not be called if [shutdown][] is called beforehand. + *args : Any, optional + Positional arguments to the callback. + **kwargs : Any, optional + Keyword arguments to the callback. + + Returns + ------- + Future + A future which will be cancelled on `shutdown`, otherwise it will hold the callback's result or an exception the callback raised. + """ + future = Future() + + def run(): + time.sleep(delay) + + if future.cancelled(): + return + + if self.is_shutdown: + future.cancel() + return + + try: + ret = callback(*args, **kwargs) + future.set_result(ret) + except Exception as e: + future.set_exception(e) + + # TODO this is a good candidate for running on an asyncio loop + threading.Thread(target=run, daemon=True).start() + return future + + def sleep(self, seconds: float) -> None: + """Wait for the specified amount of time. + + This mostly exists to provide this functionality for TOML launchfiles. + + Parameters + ---------- + seconds : float + How many seconds to wait. + """ + self.logger.info(f"Sleeping for {seconds} seconds") + time.sleep(seconds) + + def log( + self, + severity: str | int, + message: str, + *args: list[Any], + **kwargs: dict[str, Any], + ) -> None: + """Pass the specified message to the logger. + + This mostly exists to provide this functionality for TOML launchfiles. + + Parameters + ---------- + severity : str | int + A logging severity or level. Standard severities are debug, info, warning, error, critical, and fatal. Integers can be used for more fine grained control and custom + log levels, as per the python logging module. + message : str + The message to log. + args : list[Any], optional + A sequence of additional arguments to format the message. + kwargs : dict[str, Any], optional + A dict of additional arguments to format the message. + """ + if isinstance(severity, str): + level = severity_to_loglevel(severity) + else: + level = severity + + self.logger.log(level, message.format(*args, **kwargs)) diff --git a/src/lib/better_launch/better_launch/ros/__init__.py b/src/lib/better_launch/better_launch/ros/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/lib/better_launch/better_launch/ros/handlers.py b/src/lib/better_launch/better_launch/ros/handlers.py new file mode 100644 index 0000000000..8b7c297757 --- /dev/null +++ b/src/lib/better_launch/better_launch/ros/handlers.py @@ -0,0 +1,84 @@ +# Copyright 2019 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Module with handlers for launch specific logging taken from ros2 launch. + +.. seealso:: + + `ROS2 launch/handlers.py `_ +""" + +import sys +import types + + +def with_per_logger_formatting(cls): + """Add per logger formatting capabilities to the given logging.Handler.""" + class _trait(cls): + """A logging.Handler subclass to enable per logger formatting.""" + + def __init__(self, *args, **kwargs): + super(_trait, self).__init__(*args, **kwargs) + self._formatters = {} + + def setFormatterFor(self, logger, formatter): + """Set formatter for a given logger instance or logger name.""" + logger_name = logger if isinstance(logger, str) else logger.name + self._formatters[logger_name] = formatter + + def unsetFormatterFor(self, logger): + """Unset formatter for a given logger instance or logger name, if any.""" + logger_name = logger if isinstance(logger, str) else logger.name + if logger_name in self._formatters: + del self._formatters[logger_name] + + def format(self, record): # noqa + if record.name in self._formatters: + formatter = self._formatters[record.name] + return formatter.format(record) + return super(_trait, self).format(record) + + _trait.__name__ = cls.__name__ + _trait.__doc__ = cls.__doc__ + return _trait + + +# TODO(hidmic): replace module wrapper with module-level __getattr__ +# implementation when we switch to Python 3.7+ +class _module_wrapper(types.ModuleType): + """Provide all Python `logging` module handlers with per logger formatting support.""" + + def __init__(self, wrapped_module): + import logging + import logging.handlers + self._handlers = {} + for module in (logging, logging.handlers): + for name in dir(module): + if name.startswith('_'): + continue + obj = getattr(module, name) + if not isinstance(obj, type): + continue + if not issubclass(obj, logging.Handler): + continue + self._handlers[name] = with_per_logger_formatting(obj) + self._module = module + + def __getattr__(self, name): + if name in self._handlers: + return self._handlers[name] + return getattr(self._module, name) + + +sys.modules[__name__] = _module_wrapper(sys.modules[__name__]) diff --git a/src/lib/better_launch/better_launch/ros/logging.py b/src/lib/better_launch/better_launch/ros/logging.py new file mode 100644 index 0000000000..33554f1f49 --- /dev/null +++ b/src/lib/better_launch/better_launch/ros/logging.py @@ -0,0 +1,548 @@ +# Copyright 2019 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Module for the launch specific logging taken from ROS2. + +.. seealso:: + + `ROS2 rclpy/logging.py `_ + +""" + +import codecs +import datetime +import locale +import logging +import logging.handlers + +import os +import socket +import sys + +from typing import Any +from typing import List + +from . import handlers + +__all__ = [ + 'get_logger', + 'get_output_loggers', + 'handlers', + 'launch_config', + 'reset', +] + + +def _get_logging_directory(): + """ + Get logging directory path. + + Uses various environment variables to construct a logging directory path. + + Use $ROS_LOG_DIR if ROS_LOG_DIR is set and not empty. + Otherwise, use $ROS_HOME/log, using ~/.ros for ROS_HOME if not set or if empty. + + It also expands '~' to the current user's home directory, + and normalizes the path, converting the path separator if necessary. + + :return: the path to the logging directory + """ + log_dir = os.environ.get('ROS_LOG_DIR') + if not log_dir: + log_dir = os.environ.get('ROS_HOME') + if not log_dir: + log_dir = os.path.join('~', '.ros') + log_dir = os.path.join(log_dir, 'log') + return os.path.normpath(os.path.expanduser(log_dir)) + + +def _make_unique_log_dir(*, base_path): + """ + Make a unique directory for logging. + + :param: base_path for directory creation + :return: the path to the created directory + """ + while True: + now = datetime.datetime.now() + datetime_str = now.strftime('%Y-%m-%d-%H-%M-%S-%f') + log_dirname = '{0}-{1}-{2}'.format( + datetime_str, socket.gethostname(), os.getpid() + ) + log_dir = os.path.join(base_path, log_dirname) + # Check that filename does not exist + # TODO(hidmic): fix (unlikely) TOCTTOU race + if not os.path.isdir(log_dir): + os.makedirs(log_dir, exist_ok=True) + return log_dir + + +def _renew_latest_log_dir(*, log_dir): + """ + Renew the symbolic link to the latest logging directory. + + :param log_dir: the current logging directory + :return True if the link was successfully created/updated, False otherwise + """ + base_dir = os.path.dirname(log_dir) + latest_dir = os.path.join(base_dir, 'latest') + + if os.path.lexists(latest_dir): + if not os.path.islink(latest_dir): + return False + os.unlink(latest_dir) + os.symlink(log_dir, latest_dir, target_is_directory=True) + return True + + +class LaunchConfig: + """Launch Logging Configuration class.""" + + def __init__(self): + self.reset() + + def reset(self): + self._log_dir = None + self.file_handlers = {} + self.screen_handler = None + self.screen_formatter = None + self.file_formatter = None + self._log_handler_factory = None + logging.root.setLevel(logging.INFO) + self.set_screen_format('default') + self.set_log_format('default') + + @property + def level(self): + return logging.root.getEffectiveLevel() + + @level.setter + def level(self, new_level): + """ + Set up launch logging verbosity level for all loggers. + + :param new_level: the default log level used for all loggers. + """ + logging.root.setLevel(new_level) + + @property + def log_dir(self): + """Get the current log directory, generating it if necessary.""" + if self._log_dir is None: + self._log_dir = _make_unique_log_dir( + base_path=_get_logging_directory() + ) + try: + success = _renew_latest_log_dir(log_dir=self._log_dir) + if not success: + import warnings + warnings.warn( + 'Cannot create a symlink to latest log directory') + + except OSError as e: + import warnings + warnings.warn( + ('Cannot create a symlink to latest log directory: {}\n') + .format(e)) + + return self._log_dir + + @log_dir.setter + def log_dir(self, new_log_dir): + """ + Set up launch logging directory. + + :param new_log_dir: used as base path for all log file collections. + """ + if new_log_dir is not None: + if any(self.file_handlers): + import warnings + warnings.warn(( + 'Loggers have been already configured to output to log files below {}. ' + 'Proceed at your own risk.' + ).format(self._log_dir)) + if not os.path.isdir(new_log_dir): + raise ValueError('{} is not a directory'.format(new_log_dir)) + self._log_dir = new_log_dir + + @property + def log_handler_factory(self): + """Get the log_handler_factory, generating it if necessary.""" + if self._log_handler_factory is None: + if os.name != 'nt': + self._log_handler_factory = handlers.WatchedFileHandler + else: + self._log_handler_factory = handlers.FileHandler + return self._log_handler_factory + + @log_handler_factory.setter + def log_handler_factory(self, new_log_handler_factory): + """ + Set up log handler factory. + + :param new_log_handler_factory: a callable to build log file handler. + It takes a path to the log file and it must return a `logging.Handler` + variant with per logger formatting support. + See `launch.logging.handlers` module for further reference and easy reuse + of existing standard `logging.handlers` module handlers. + Defaults to regular log file handlers for logging if no factory is given. + """ + self._log_handler_factory = new_log_handler_factory + + def set_screen_format(self, screen_format, *, screen_style=None): + """ + Set up screen formats. + + For the ``screen_format`` argument there are a few aliases: + + - 'default' to log verbosity level, logger name and logged message + - 'default_with_timestamp' to add timestamps to the 'default' format + + :param screen_format: format specification used when logging to the screen, + as expected by the `logging.Formatter` constructor. + Alternatively, aliases for common formats are available, see above. + This format can also be overridden by the environment variable + 'OVERRIDE_LAUNCH_SCREEN_FORMAT'. + :param screen_style: the screen style used if no alias is used for + screen_format. + No style can be provided if a format alias is given. + """ + # Check if the environment variable is set + screen_format_env = os.environ.get('OVERRIDE_LAUNCH_SCREEN_FORMAT') + # If the environment variable is set override the given format + if screen_format_env not in [None, '']: + # encoded escape characters correctly + screen_format = screen_format_env.encode( + 'latin1').decode('unicode_escape') + # Set the style correspondingly + screen_style = '{' + if screen_format is not None: + if screen_format == 'default': + screen_format = '[{levelname}] [{name}]: {msg}' + if screen_style is not None: + raise ValueError( + 'Cannot set a custom format style for the "default" screen format.' + ) + if screen_format == 'default_with_timestamp': + screen_format = '{created:.7f} [{levelname}] [{name}]: {msg}' + if screen_style is not None: + raise ValueError( + 'Cannot set a custom format style for the ' + '"default_with_timestamp" screen format.' + ) + if screen_style is None: + screen_style = '{' + self.screen_formatter = logging.Formatter( + screen_format, style=screen_style + ) + if self.screen_handler is not None: + self.screen_handler.setFormatter(self.screen_formatter) + else: + self.screen_formatter = None + + def get_screen_handler(self): + """ + Get the one and only screen logging handler. + + See launch_config() documentation for screen logging configuration. + """ + if self.screen_handler is None: + stream = codecs.StreamWriter(sys.stdout, errors='replace') + stream.encode = lambda msg, errors='replace': ( + msg.encode(locale.getpreferredencoding(False), errors).decode( + locale.getpreferredencoding(False), errors=errors), + msg) + self.screen_handler = handlers.StreamHandler(stream) + self.screen_handler.setFormatter(self.screen_formatter) + return self.screen_handler + + def set_log_format(self, log_format, *, log_style=None): + """ + Set up launch log file format. + + :param log_format: the format used when logging to the main launch log file, + as expected by the `logging.Formatter` constructor. + Alternatively, the 'default' alias can be given to log verbosity level, + logger name and logged message. + This format can also be overridden by the environment variable + 'OVERRIDE_LAUNCH_LOG_FORMAT'. + :param log_style: the log style used if no alias is given for log_format. + No style can be provided if a format alias is given. + """ + # Check if the environment variable is set + log_format_env = os.environ.get('OVERRIDE_LAUNCH_LOG_FORMAT') + # If the environment variable is set override the given format + if log_format_env not in [None, '']: + # encoded escape characters correctly + log_format = log_format_env.encode( + 'latin1').decode('unicode_escape') + # Set the style correspondingly + log_style = '{' + if log_format is not None: + if log_format == 'default': + log_format = '{created:.7f} [{levelname}] [{name}]: {msg}' + if log_style is not None: + raise ValueError( + 'Cannot set a custom format style for the "default" log format.' + ) + if log_style is None: + log_style = '{' + self.file_formatter = logging.Formatter( + log_format, style=log_style + ) + for handler in self.file_handlers.values(): + handler.setFormatter(self.file_formatter) + else: + self.file_formatter = None + + def get_log_file_path(self, file_name='launch.log'): + """ + Get the absolute path to the given log file. + + :param: file_name of the log file from which to get the absolute path. + :return: the absolute path to the log file. + """ + return os.path.join(self.log_dir, file_name) + + def get_log_file_handler(self, file_name='launch.log'): + """ + Get the logging handler to a log file. + + See launch_config() documentation for application wide log file + logging configuration. + + :param: file_name of the log file whose handler is to be retrieved. + :return: the logging handler associated to the file (always the same + once constructed). + """ + if file_name not in self.file_handlers: + file_path = self.get_log_file_path(file_name) + factory = self.log_handler_factory + file_handler = factory(file_path, encoding='utf-8') + file_handler.setFormatter(self.file_formatter) + self.file_handlers[file_name] = file_handler + return self.file_handlers[file_name] + + +launch_config = LaunchConfig() + + +def log_launch_config(*, logger=logging.root): + """Log logging configuration details relevant for a user with the given logger.""" + if any(launch_config.file_handlers): + logger.info('All log files can be found below {}'.format(launch_config.log_dir)) + logger.info('Default logging verbosity is set to {}'.format(logging.getLevelName( + logging.root.getEffectiveLevel() + ))) + + +def get_logger(name=None) -> logging.Logger: + """Get named logger, configured to output to screen and launch main log file.""" + logger = logging.getLogger(name) + screen_handler = launch_config.get_screen_handler() + if screen_handler not in logger.handlers: + logger.addHandler(screen_handler) + launch_log_file_handler = launch_config.get_log_file_handler() + if launch_log_file_handler not in logger.handlers: + logger.addHandler(launch_log_file_handler) + return logger + + +def _normalize_output_configuration(config): + """ + Normalize output configuration to a dict representation. + + See `get_output_loggers()` documentation for further reference. + """ + normalized_config = { + 'both': set(), 'stdout': set(), 'stderr': set() + } + if isinstance(config, str): + if config == 'screen': + normalized_config.update({ + 'both': {'screen'} + }) + elif config == 'log': + normalized_config.update({ + 'both': {'log'}, + 'stderr': {'screen'} + }) + elif config == 'both': + normalized_config.update({ + 'both': {'log', 'screen'}, + }) + elif config == 'own_log': + normalized_config.update({ + 'both': {'own_log'}, + 'stdout': {'own_log'}, + 'stderr': {'own_log'} + }) + elif config == 'full': + normalized_config.update({ + 'both': {'screen', 'log', 'own_log'}, + 'stdout': {'own_log'}, + 'stderr': {'own_log'} + }) + else: + raise ValueError(( + '{} is not a valid standard output config ' + 'i.e. "screen", "log" or "both"' + ).format(config)) + elif isinstance(config, dict): + for source, destinations in config.items(): + if source not in ('stdout', 'stderr', 'both'): + raise ValueError(( + '{} is not a valid output source ' + 'i.e. "stdout", "stderr" or "both"' + ).format(source)) + if isinstance(destinations, str): + destinations = {destinations} + for destination in destinations: + if destination not in ('screen', 'log', 'own_log'): + raise ValueError(( + '{} is not a valid output destination ' + 'i.e. "screen", "log" or "own_log"' + ).format(destination)) + normalized_config[source] = set(destinations) + else: + raise ValueError( + '{} is not a valid output configuration'.format(config) + ) + return normalized_config + + +def get_output_loggers(process_name, output_config): + """ + Get the stdout and stderr output loggers for the given process name. + + The output_config may be a dictionary with one or more of the optional keys + 'stdout', 'stderr', or 'both' (stdout and stderr combined) which represent + the various process output sources, and values for those keys to assign one + or more logging destinations to the source. + The logging destination values may be: + + - 'screen': log it to the screen, + - 'log': log it to launch log file, or + - 'own_log': log it to a separate log file. + + When logging the stdout and stderr separately, the log file names follow + the ``-.log`` pattern where ```` is either + 'stdout' or 'stderr' + When the 'both' logging destination is used the log file name follows the + ``.log`` pattern. + + The "launch log file" is a log file which is create for each run of + the launch.LaunchService, and at least captures the log output from launch + itself, but may also include output from subprocess's if configured so. + + Alternatively, the output_config parameter may be a string which represents + one of a couple available aliases for common logging configurations. + The available aliases are: + + - 'screen': stdout and stderr are logged to the screen, + - 'log': stdout and stderr are logged to launch log file and stderr to + the screen, + - 'both': both stdout and stderr are logged to the screen and to launch + main log file, + - 'own_log' for stdout, stderr and their combination to be logged to + their own log files, and + - 'full' to have stdout and stderr sent to the screen, to the main launch + log file, and their own separate and combined log files. + + :param process_name: the process-like action whose outputs want to be logged. + :param output_config: configuration for the output loggers, + see above for details. + :returns: a tuple with the stdout and stderr output loggers. + """ + output_config = _normalize_output_configuration(output_config) + for source in ('stdout', 'stderr'): + logger = logging.getLogger('{}-{}'.format(process_name, source)) + # If a 'screen' output is configured for this source or for + # 'both' sources, this logger should output to screen. + if 'screen' in (output_config['both'] | output_config[source]): + screen_handler = launch_config.get_screen_handler() + # Add screen handler if necessary. + if screen_handler not in logger.handlers: + screen_handler.setFormatterFor( + logger, logging.Formatter('{msg}', style='{') + ) + logger.addHandler(screen_handler) + + # If a 'log' output is configured for this source or for + # 'both' sources, this logger should output to launch main log file. + if 'log' in (output_config['both'] | output_config[source]): + launch_log_file_handler = launch_config.get_log_file_handler() + # Add launch main log file handler if necessary. + if launch_log_file_handler not in logger.handlers: + launch_log_file_handler.setFormatterFor( + logger, logging.Formatter('{created:.7f} {msg}', style='{') + ) + logger.addHandler(launch_log_file_handler) + + # If an 'own_log' output is configured for this source, this logger + # should output to its own log file. + if 'own_log' in output_config[source]: + own_log_file_handler = launch_config.get_log_file_handler( + '{}-{}.log'.format(process_name, source) + ) + own_log_file_handler.setFormatter(logging.Formatter(fmt=None)) + # Add own log file handler if necessary. + if own_log_file_handler not in logger.handlers: + logger.addHandler(own_log_file_handler) + # If an 'own_log' output is configured for 'both' sources, + # this logger should output to a combined log file. + if 'own_log' in output_config['both']: + combined_log_file_handler = launch_config.get_log_file_handler(process_name + '.log') + combined_log_file_handler.setFormatter(logging.Formatter('{msg}', style='{')) + # Add combined log file handler if necessary. + if combined_log_file_handler not in logger.handlers: + logger.addHandler(combined_log_file_handler) + # Retrieve both loggers. + return ( + logging.getLogger(process_name + '-stdout'), + logging.getLogger(process_name + '-stderr') + ) + + +# Mypy does not support dynamic base classes, so workaround by typing the base +# class as Any +_Base: Any = logging.getLoggerClass() + + +# Track all loggers to support module resets +class LaunchLogger(_Base): + all_loggers: List[logging.Logger] = [] + + def __new__(cls, *args, **kwargs): + instance = super(LaunchLogger, cls).__new__(cls) + LaunchLogger.all_loggers.append(instance) + return instance + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.propagate = False + + +def reset(): + """Reset logging.""" + # Reset existing logging infrastructure + for logger in LaunchLogger.all_loggers: + logger.setLevel(logging.NOTSET) + del logger.handlers[:] + launch_config.reset() + logging.setLoggerClass(LaunchLogger) + + +# Initial module reset +reset() \ No newline at end of file diff --git a/src/lib/better_launch/better_launch/ros/ros_adapter.py b/src/lib/better_launch/better_launch/ros/ros_adapter.py new file mode 100644 index 0000000000..e236db2376 --- /dev/null +++ b/src/lib/better_launch/better_launch/ros/ros_adapter.py @@ -0,0 +1,66 @@ +import os +import threading +import rclpy +from rclpy.executors import SingleThreadedExecutor + +""" +Provides a shared node for interacting with ROS2. Taken from ROS2. + +.. seealso:: + + `launch_ros/ros_adapters.py `_ +""" + +class ROSAdapter: + """Wraps rclpy API to ease ROS node usage in `launch_ros` actions.""" + + def __init__(self, *, argv: list[str] = None, autostart: bool = True): + """ + Construct adapter. + + :param: argv List of global arguments for rclpy context initialization. + :param: autostart Whether to start adapter on construction or not. + """ + # Do not use `None` here, as `rclpy.init` will use `sys.argv` in that case. + self.argv = [] if argv is None else argv + self.ros_context = None + self.ros_node = None + self.ros_executor = None + self._thread = None + + if autostart: + self.start() + + def start(self): + """Start ROS adapter.""" + if self._thread and self._thread.is_alive(): + raise RuntimeError("Cannot start a ROS adapter that is already running") + + self.ros_context = rclpy.Context() + rclpy.init(args=self.argv, context=self.ros_context) + self.ros_node = rclpy.create_node( + "better_launch_{}".format(os.getpid()), context=self.ros_context + ) + self.ros_executor = SingleThreadedExecutor(context=self.ros_context) + + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def _run(self): + try: + self.ros_executor.add_node(self.ros_node) + self.ros_executor.spin() + except KeyboardInterrupt: + pass + finally: + self.ros_executor.remove_node(self.ros_node) + + def shutdown(self): + """Shutdown ROS adapter.""" + if not self._thread or not self._thread.is_alive(): + return + + self.ros_executor.shutdown() + self._thread.join() + self.ros_node.destroy_node() + rclpy.shutdown(context=self.ros_context) diff --git a/src/lib/better_launch/better_launch/toml/__init__.py b/src/lib/better_launch/better_launch/toml/__init__.py new file mode 100644 index 0000000000..b0912cd9cb --- /dev/null +++ b/src/lib/better_launch/better_launch/toml/__init__.py @@ -0,0 +1,2 @@ +from .substitutions import apply_substitutions +from .toml_parser import TomlReader, load, loads diff --git a/src/lib/better_launch/better_launch/toml/substitutions.py b/src/lib/better_launch/better_launch/toml/substitutions.py new file mode 100644 index 0000000000..cf5ecf42bd --- /dev/null +++ b/src/lib/better_launch/better_launch/toml/substitutions.py @@ -0,0 +1,482 @@ +from typing import Any, Callable +import os +from ast import literal_eval +from functools import partial +from enum import Enum + +from better_launch import BetterLaunch + + +_sentinel = object() + + +class SubstitutionError(ValueError): + """Exception type that will be thrown by substitution handlers.""" + + pass + + +class EvalMode(Enum): + NONE = "none" + LITERAL = "literal" + FULL = "full" + + +def _parse_substitutions(s: str) -> list[list | str]: + """Parses a string containing substitution tokens into a list of lists and strings. + + Supports ${key args} syntax, including substitutions nested inside quoted strings, + e.g. ${sub "${other arg}"}. A quoted string that contains substitutions is emitted + as a list whose first element is "$" (concat marker): the handler should resolve + each piece and join the results into a single string. + + Parameters + ---------- + s : str + The input string to parse. + + Returns + ------- + list[list | str] + A list containing unchanged strings and nested lists of strings/lists. Nested + lists starting with "$key" are substitutions; nested lists starting with "$" + (just the dollar) are concat groups from quoted strings containing substitutions. + + Raises + ------ + ValueError + If the input string contains unbalanced braces or quotes. + """ + + # token sentinels: distinct objects so they can't collide with real string content + QUOTE_OPEN = ("QUOTE_OPEN",) + QUOTE_CLOSE = ("QUOTE_CLOSE",) + SUB_OPEN = ("SUB_OPEN",) + SUB_CLOSE = ("SUB_CLOSE",) + + def tokenize(s): + i = 0 + n = len(s) + # stack of context frames: ("quote", quote_char) or ("sub",). We are + # "in quotes" only when the top of the stack is a quote frame; entering + # a ${...} inside a quoted string suspends the quote context until + # that inner substitution closes. + ctx = [] + # True when the next non-space char starts a fresh token at a boundary + # (whitespace, sub open, sub close, or start of input). Only at such + # boundaries does a quote open a quoted region. Mid-token quotes (e.g. + # the ' in ['x']) are treated as literal text, matching the original + # parser's behavior for non-leading quotes. + at_arg_start = True + + def in_quote_top(): + return ctx and ctx[-1][0] == "quote" + + def in_sub_top(): + return ctx and ctx[-1][0] == "sub" + + while i < n: + c = s[i] + in_quotes = in_quote_top() + + if not in_quotes and c.isspace(): + start = i + while i < n and s[i].isspace(): + i += 1 + yield s[start:i] + at_arg_start = True + + elif c == "$" and i + 1 < n and s[i + 1] == "{": + ctx.append(("sub",)) + yield SUB_OPEN + i += 2 + at_arg_start = False # key comes first, not an arg + + elif c == "}": + if in_sub_top(): + ctx.pop() + yield SUB_CLOSE + i += 1 + # SUB_CLOSE does NOT reset at_arg_start: a quote right after + # }' continues the surrounding text token (e.g. ['${sub}'] + # — the trailing ' closes the Python literal, doesn't open + # a new quoted region). + at_arg_start = False + else: + raise ValueError(f"Unexpected '}}' at position {i}") + + elif c in "\"'" and in_quotes and ctx[-1][1] == c: + # closing the matching quote + ctx.pop() + yield QUOTE_CLOSE + i += 1 + at_arg_start = False + + elif c in "\"'" and not in_quotes and at_arg_start: + # opening a quote at a token boundary (top level or inside a sub) + ctx.append(("quote", c)) + yield QUOTE_OPEN + i += 1 + at_arg_start = False + + elif in_quotes: + # literal run inside quotes: stops at ${sub}, matching quote, or escape + start = i + buf = [] + quote = ctx[-1][1] + while i < n: + ch = s[i] + if ch == "\\" and i + 1 < n: + buf.append(s[i + 1]) + i += 2 + continue + if ch == quote: + break + if ch == "$" and i + 1 < n and s[i + 1] == "{": + break + buf.append(ch) + i += 1 + else: + raise ValueError(f"Missing closing quote at position {start - 1}") + if buf: + yield "".join(buf) + + else: + # Regular text. Inside a sub, support backslash escapes so users + # can write \" for a literal quote without opening a quoted region. + # Quotes are NOT delimiters here (they only matter at arg boundaries, + # handled above), so mid-token quotes flow through as text — this + # preserves the original parser's behavior on strings like ['x']. + in_sub = in_sub_top() + start = i + if in_sub: + buf = [] + while i < n and not s[i].isspace() and s[i] not in "${}": + if s[i] == "\\" and i + 1 < n: + buf.append(s[i + 1]) + i += 2 + continue + buf.append(s[i]) + i += 1 + if i == start: + buf.append(s[i]) + i += 1 + yield "".join(buf) + else: + # true top-level text: original behavior, no escape processing, + # and quotes terminate the run only to keep their old semantics + while i < n and not s[i].isspace() and s[i] not in "${}\"'": + i += 1 + if i == start: + i += 1 + yield s[start:i] + at_arg_start = False + + # any unclosed quote frame is an error; unclosed subs caught in parse() + for frame in ctx: + if frame[0] == "quote": + raise ValueError("Missing closing quote") + + def parse(tokens): + # frame = (current_list, in_substitution, key_pending, current_arg_buf) + # current_arg_buf accumulates adjacent (no-whitespace-between) pieces + # for the current argument; whitespace flushes it. A flushed arg is a + # single string when all pieces are strings, else a ["$", ...] concat + # group whose handler should resolve each piece and join them. + stack = [] + current = [] + is_key = False + in_substitution = False + in_quote = False + quote_pieces = None + arg_buf = [] # pieces of the in-progress arg (or text token at top level) + + def flush_arg(): + nonlocal arg_buf + if not arg_buf: + return + if len(arg_buf) == 1: + # single piece: emit as-is (string or nested sub list) + current.append(arg_buf[0]) + elif all(isinstance(p, str) for p in arg_buf): + # multiple but all strings: concat now, save a wrap + current.append("".join(arg_buf)) + else: + current.append(["$"] + arg_buf) + arg_buf = [] + + def push_piece(p): + # add a piece to the current arg; quoted regions accumulate + # separately and become a single piece when the quote closes + if in_quote: + quote_pieces.append(p) + else: + arg_buf.append(p) + + for tok in tokens: + if tok is SUB_OPEN: + # do NOT flush arg_buf: text immediately before a ${...} is part + # of the same arg as the substitution (e.g. ['${x}'] is one arg + # whose pieces are "['", the sub, and "']") + stack.append( + (current, in_substitution, is_key, in_quote, quote_pieces, arg_buf) + ) + current = [] + is_key = True + in_substitution = True + in_quote = False + quote_pieces = None + arg_buf = [] + + elif tok is SUB_CLOSE: + if not stack: + raise ValueError("Unbalanced '}' - no matching '${'") + flush_arg() + completed = current + current, in_substitution, is_key, in_quote, quote_pieces, arg_buf = ( + stack.pop() + ) + # the sub result attaches to the current arg, not as its own arg — + # so adjacent text like ['${sub}'] groups together + push_piece(completed) + + elif tok is QUOTE_OPEN: + if in_substitution and not is_key: + in_quote = True + quote_pieces = [] + # else: drop (top-level quotes also handled implicitly by + # the tokenizer when they're not at an arg boundary) + + elif tok is QUOTE_CLOSE: + if in_quote: + # attach the quoted content as a single piece to current arg + if not quote_pieces: + arg_buf.append("") + elif len(quote_pieces) == 1: + arg_buf.append(quote_pieces[0]) + elif all(isinstance(p, str) for p in quote_pieces): + arg_buf.append("".join(quote_pieces)) + else: + arg_buf.append(["$"] + quote_pieces) + in_quote = False + quote_pieces = None + # else: stray closing quote — drop + + elif isinstance(tok, str): + if tok.isspace(): + if in_quote: + # whitespace inside quotes is literal (the tokenizer + # shouldn't yield this, but guard anyway) + quote_pieces.append(tok) + elif in_substitution: + # arg separator: flush current arg buffer + flush_arg() + else: + # top level: whitespace is a token of its own, but + # only flush after the arg before it + flush_arg() + current.append(tok) + continue + if is_key: + tok = "$" + tok + is_key = False + # the key is its own "arg" — flush immediately so following + # whitespace doesn't try to attach to it + current.append(tok) + continue + push_piece(tok) + + flush_arg() + + if stack: + raise ValueError("Unbalanced '${' - missing closing '}'") + if in_quote: + raise ValueError("Missing closing quote") + + return current + + return parse(tokenize(s)) + + +# ${param /myrobot/my_node rate} +def sub_param(full_node_name: str, param: str): + # Try to delay ROS2 imports until we actually need them + from rcl_interfaces.srv import GetParameters + from rcl_interfaces.msg import ParameterType + + bl = BetterLaunch.instance() + + srv = bl.shared_node.create_client( + GetParameters, f"{full_node_name}/get_parameters" + ) + + if not srv.wait_for_service(5.0): + raise SubstitutionError("Failed to wait for node parameter service") + + req = GetParameters.Request() + req.names = [param] + res = srv.call(req) + + if len(res.values) != 1: + raise SubstitutionError( + f"Failed to retrieve parameter {param} from {full_node_name}" + ) + + value = res.values[0] + + # Importing get_value from ros2param will increase memory footprint by ~5MB, so diy + if value.type == ParameterType.PARAMETER_BOOL: + return value.bool_value + elif value.type == ParameterType.PARAMETER_INTEGER: + return value.integer_value + elif value.type == ParameterType.PARAMETER_DOUBLE: + return value.double_value + elif value.type == ParameterType.PARAMETER_STRING: + return value.string_value + elif value.type == ParameterType.PARAMETER_BYTE_ARRAY: + return list(value.byte_array_value) + elif value.type == ParameterType.PARAMETER_BOOL_ARRAY: + return list(value.bool_array_value) + elif value.type == ParameterType.PARAMETER_INTEGER_ARRAY: + return list(value.integer_array_value) + elif value.type == ParameterType.PARAMETER_DOUBLE_ARRAY: + return list(value.double_array_value) + elif value.type == ParameterType.PARAMETER_STRING_ARRAY: + return list(value.string_array_value) + elif value.type == ParameterType.PARAMETER_NOT_SET: + return None + + return None + + +# ${env ROS_DISTRO} +def sub_env(key: str, default: Any = _sentinel): + if default != _sentinel: + return os.environ.get(key, default) + return os.environ[key] + + +# ${eval ${arg x} * 5} +def sub_eval(*args, context: dict, eval_mode: EvalMode): + expr = " ".join(str(arg) for arg in args) + # print("###", expr) + if eval_mode == EvalMode.FULL: + return eval(expr, {}, dict(context) if context else {}) + elif eval_mode == EvalMode.LITERAL: + return literal_eval(expr) + else: + # eval was disabled + raise RuntimeError(f"eval substitutions have been disabled ({args})") + + +def apply_substitutions( + value: str, + substitutions: dict[str, Callable] = None, + context: dict[str, Any] = None, + *, + eval_mode: EvalMode = EvalMode.NONE, +) -> Any: + """Applies substitutions to a string. + + Substitution strings are expected to follow the pattern: `${key: *args}`, where `key` is a + substitution type and `*args` are additional arguments to the substitution handler. + + If no other substitutions are specified, this function will handle the following ones: + - ${env: [default]} + - ${param: } + - ${eval: [python-strings]} + + Substitutions other than those above will be looked up in the provided `context` dict. + + Parameters + ---------- + value : str + A string that may contain substitution tokens. + substitutions : dict[str, Callable], optional + Custom substitution handlers. If None, defaults to param, env, and eval. + context : dict[str, Any], optional + A dict containing additional values the substitution may use. + eval_type : Literal["full", "literal", "none"], optional + If and to what extent `eval` should be allowed. Default is "full". + + Returns + ------- + str + The input string with all substitution tokens handled. + + Raises + ------ + ValueError + If the input string contains unbalanced braces or quotes. + SubstitutionError + If a substitution handler fails. + """ + if not isinstance(value, str): + raise ValueError(f"Value is not a string ({value})") + + if not context: + context = {} + + if not substitutions: + _eval = partial(sub_eval, context=context, eval_mode=eval_mode) + + substitutions = { + "param:": sub_param, + "env:": sub_env, + "eval:": _eval, + } + + # Handle empty strings early + if not value: + return value + + # This should only raise if the value contains "${" AND has invalid syntax + parsed = _parse_substitutions(value) + + # Evaluate the substitutions + def delve(node: list | str) -> str: + if isinstance(node, list): + # Empty list means empty substitution like ${} + if not node: + raise SubstitutionError("Empty substitution token") + + if node[0] == "$": + return "".join(str(delve(p)) for p in node[1:]) + + # Evaluate nested elements first + evaluated = [delve(token) for token in node] + sub_key, *sub_args = evaluated + + # Substitution keys will start with a $ (see parse() above) + if sub_key.startswith("$"): + sub_key = sub_key[1:] + + if sub_key in substitutions: + return substitutions[sub_key](*sub_args) + + # If the key is not a substitution key, assume it's the name of a launch + # arg or call result + if sub_key in context: + return context[sub_key] + else: + raise SubstitutionError( + f"Unknown substitution key: {sub_key} (substitutions: {list(substitutions.keys())}, context: {list(context.keys())})" + ) + else: + return " ".join(str(e) for e in evaluated) + else: + return node + + if isinstance(parsed, list): + if not parsed: + return "" + elif len(parsed) == 1 and isinstance(parsed[0], str): + # The entire input is just plain text (no substitutions) + return parsed[0] + elif len(parsed) == 1 and isinstance(parsed[0], list): + # For "pure" substitutions like "${abc}" return the actual value instead of its string + return delve(parsed[0]) + else: + return "".join(str(delve(item)) for item in parsed) + + return delve(parsed) diff --git a/src/lib/better_launch/better_launch/toml/toml_parser.py b/src/lib/better_launch/better_launch/toml/toml_parser.py new file mode 100644 index 0000000000..92e5c79ea0 --- /dev/null +++ b/src/lib/better_launch/better_launch/toml/toml_parser.py @@ -0,0 +1,574 @@ +from typing import Any +import re + + +class TomlReader: + def __init__(self, text: str | list[str]): + """TOML parser with better comment handling compared to existing ones. + + Noticably, this parser will preserve and associate comments that appear *before* the key they are describing. Comments appear in the same dict as their associated key under the key `__comment___`. In addition, a comment at the start of the file will be retained as well. This root comment has the key `__comment__`. + + Some standard features are not supported right now: + - octal integers (0oXXXX) + - dates and times remain strings + - arrays of tables ([[my-aot]]) + + The following non-standard features are supported: + - write `key = ` to set its value to the corresponding class. Base types are int, float, str, bool, list, dict. + + Parameters + ---------- + text : str | list[str] + The toml content to be parsed. + """ + if isinstance(text, list): + self.lines = text + else: + self.lines = text.splitlines() + + self.data: dict[str, Any] = {} + self.current_table = self.data + self.table_path: list[str] = [] + + def parse(self) -> dict[str, Any]: + """Parse TOML text and return a dictionary.""" + i = 0 + pending_comment: str = None + root_comment: str = None + root_comment_done = False + + while i < len(self.lines): + line = self.lines[i].strip() + + # Empty line resets pending comment (and marks end of root comment section) + if not line: + if pending_comment and not root_comment_done and not self.table_path: + root_comment = pending_comment + root_comment_done = True + pending_comment = None + i += 1 + continue + + # Comment line + if line.startswith("#"): + comment_text = line[1:].strip() + if pending_comment is None: + pending_comment = comment_text + else: + pending_comment += "\n" + comment_text + i += 1 + continue + + # Table header + if line.startswith("["): + if line.startswith("[["): + raise NotImplementedError("Array of tables [[...]] not supported") + + match = re.match(r"\[([^\]]+)\]", line) + if not match: + raise ValueError(f"Invalid table header: {line}") + + table_name = match.group(1).strip() + + # Save root comment if we haven't yet and no table has been set + if not root_comment_done and not self.table_path and pending_comment: + root_comment = pending_comment + root_comment_done = True + pending_comment = None + + self._set_table(table_name) + + if pending_comment: + self.current_table["__comment__"] = pending_comment + pending_comment = None + + i += 1 + continue + + # Key-value pair (check if line contains = but isn't inside a comment) + if "=" in line and not line.startswith("#"): + key, value, lines_consumed = self._parse_key_value(line, i) + self._set_dotted_key(key, value) + + # Associate comment with key + if pending_comment: + # For dotted keys, store comment with the final key + if "." in key: + parts = key.split(".") + final_key = parts[-1] + target = self._navigate_to_parent(parts[:-1]) + target[f"__comment_{final_key}__"] = pending_comment + else: + self.current_table[f"__comment_{key}__"] = pending_comment + pending_comment = None + + i += lines_consumed + continue + + raise ValueError(f"Unexpected line: {line}") + + # Set root comment at the end + if root_comment: + self.data["__comment__"] = root_comment + + return self.data + + def _set_table(self, table_path: str): + """Set the current table based on dot-separated path.""" + parts = [p.strip().strip('"').strip("'") for p in table_path.split(".")] + self.table_path = parts + self.current_table = self.data + + for part in parts: + if part not in self.current_table: + self.current_table[part] = {} + self.current_table = self.current_table[part] + + def _set_dotted_key(self, key: str, value: Any): + """Set a value using dotted key notation.""" + if "." not in key: + self.current_table[key] = value + return + + parts = key.split(".") + target = self.current_table + + for part in parts[:-1]: + part = part.strip().strip('"').strip("'") + if part not in target: + target[part] = {} + target = target[part] + + final_key = parts[-1].strip().strip('"').strip("'") + target[final_key] = value + + def _navigate_to_parent(self, parts: list[str]) -> dict[str, Any]: + """Navigate to parent table for dotted key.""" + target = self.current_table + for part in parts: + part = part.strip().strip('"').strip("'") + if part not in target: + target[part] = {} + target = target[part] + return target + + def _parse_key_value(self, line: str, line_idx: int) -> tuple[str, Any, int]: + """Parse a key-value pair, potentially spanning multiple lines.""" + key, _, value_str = line.partition("=") + key = key.strip() + value_str = value_str.strip() + + lines_consumed = 1 + + # Handle multi-line strings first + if value_str.startswith('"""') or value_str.startswith("'''"): + quote = '"""' if value_str.startswith('"""') else "'''" + # Check if string closes on same line + if value_str.count(quote) >= 2: + # Closed on same line + pass + else: + # Multi-line string - read until closing quotes + full_value = value_str + idx = line_idx + 1 + while idx < len(self.lines): + full_value += "\n" + self.lines[idx] + lines_consumed += 1 + if quote in self.lines[idx]: + break + idx += 1 + value_str = full_value + # Check if we need to read multiple lines for arrays or inline dicts + elif ( + value_str.startswith("[") or value_str.startswith("{") + ) and not self._is_closed(value_str): + full_value = value_str + idx = line_idx + 1 + while idx < len(self.lines) and not self._is_closed(full_value): + full_value += " " + self.lines[idx].strip() + lines_consumed += 1 + idx += 1 + value_str = full_value + + value_str = self._remove_inline_comment(value_str) + value = self._parse_value(value_str) + return key, value, lines_consumed + + def _is_closed(self, s: str) -> bool: + """Check if brackets/braces are balanced.""" + stack = [] + in_string = False + string_char = None + i = 0 + + while i < len(s): + char = s[i] + + if char in ('"', "'") and (i == 0 or s[i - 1] != "\\"): + if not in_string: + in_string = True + string_char = char + elif char == string_char: + in_string = False + string_char = None + elif not in_string: + if char in "[{": + stack.append(char) + elif char == "]": + if not stack or stack[-1] != "[": + return False + stack.pop() + elif char == "}": + if not stack or stack[-1] != "{": + return False + stack.pop() + i += 1 + + return len(stack) == 0 + + def _remove_inline_comment(self, value_str: str) -> str: + """Remove inline comments, respecting strings.""" + in_string = False + string_char = None + i = 0 + + while i < len(value_str): + char = value_str[i] + + # Handle triple-quoted strings + if i + 2 < len(value_str): + triple = value_str[i : i + 3] + if triple in ('"""', "'''"): + if not in_string: + in_string = True + string_char = triple + i += 3 + continue + elif string_char == triple: + in_string = False + string_char = None + i += 3 + continue + + if char in ('"', "'") and (i == 0 or value_str[i - 1] != "\\"): + if not in_string: + in_string = True + string_char = char + elif char == string_char and len(string_char) == 1: + in_string = False + string_char = None + elif char == "#" and not in_string: + return value_str[:i].strip() + + i += 1 + + return value_str.strip() + + def _parse_value(self, value_str: str) -> Any: + """Parse a TOML value.""" + value_str = value_str.strip() + + # Type annotations: key = int, key = str, etc. + type_map = { + "int": int, + "float": float, + "str": str, + "bool": bool, + "list": list, + "dict": dict, + } + if value_str in type_map: + return type_map[value_str] + + # Multi-line basic string + if value_str.startswith('"""') and value_str.endswith('"""'): + content = value_str[3:-3] + if content.startswith("\n"): + content = content[1:] + return self._process_escapes(content) + + # Multi-line literal string + if value_str.startswith("'''") and value_str.endswith("'''"): + content = value_str[3:-3] + if content.startswith("\n"): + content = content[1:] + return content + + # Basic string + if value_str.startswith('"') and value_str.endswith('"'): + return self._process_escapes(value_str[1:-1]) + + # Literal string + if value_str.startswith("'") and value_str.endswith("'"): + return value_str[1:-1] + + # Boolean + if value_str.lower() == "true": + return True + if value_str.lower() == "false": + return False + + # Special float values + if value_str in ("inf", "+inf"): + return float("inf") + if value_str == "-inf": + return float("-inf") + if value_str in ("nan", "+nan", "-nan"): + return float("nan") + + # Inline dict + if value_str.startswith("{") and value_str.endswith("}"): + return self._parse_inline_dict(value_str[1:-1]) + + # Array + if value_str.startswith("[") and value_str.endswith("]"): + return self._parse_array(value_str[1:-1]) + + # Hexadecimal integer + if value_str.startswith("0x"): + return int(value_str, 16) + + # Binary integer + if value_str.startswith("0b"): + return int(value_str, 2) + + # Octal integer + if value_str.startswith("0o"): + return int(value_str, 8) + + # Number with underscores + value_str_clean = value_str.replace("_", "") + try: + if "." in value_str_clean or "e" in value_str_clean.lower(): + return float(value_str_clean) + return int(value_str_clean) + except ValueError: + pass + + return value_str + + def _process_escapes(self, s: str) -> str: + """Process escape sequences in strings.""" + result = [] + i = 0 + while i < len(s): + if s[i] == "\\" and i + 1 < len(s): + next_char = s[i + 1] + if next_char == "n": + result.append("\n") + i += 2 + elif next_char == "t": + result.append("\t") + i += 2 + elif next_char == "r": + result.append("\r") + i += 2 + elif next_char == "\\": + result.append("\\") + i += 2 + elif next_char == '"': + result.append('"') + i += 2 + elif next_char == "b": + result.append("\b") + i += 2 + elif next_char == "f": + result.append("\f") + i += 2 + elif next_char == "u" and i + 5 < len(s): + hex_code = s[i + 2 : i + 6] + try: + result.append(chr(int(hex_code, 16))) + i += 6 + except ValueError: + result.append(s[i]) + i += 1 + elif next_char == "U" and i + 9 < len(s): + hex_code = s[i + 2 : i + 10] + try: + result.append(chr(int(hex_code, 16))) + i += 10 + except ValueError: + result.append(s[i]) + i += 1 + else: + result.append(s[i]) + i += 1 + else: + result.append(s[i]) + i += 1 + return "".join(result) + + def _parse_inline_dict(self, dict_str: str) -> dict[str, Any]: + """Parse an inline TOML dictionary.""" + dict_str = dict_str.strip() + if not dict_str: + return {} + + result = {} + current = "" + depth = 0 + in_string = False + string_char = None + + for char in dict_str: + if char in ('"', "'") and not in_string: + in_string = True + string_char = char + current += char + elif char == string_char and in_string: + in_string = False + string_char = None + current += char + elif char in "[{" and not in_string: + depth += 1 + current += char + elif char in "]}" and not in_string: + depth -= 1 + current += char + elif char == "," and depth == 0 and not in_string: + if current.strip() and "=" in current: + key, value = self._parse_inline_dict_pair(current.strip()) + result[key] = value + current = "" + else: + current += char + + if current.strip() and "=" in current: + key, value = self._parse_inline_dict_pair(current.strip()) + result[key] = value + + return result + + def _parse_inline_dict_pair(self, pair_str: str) -> tuple[str, Any]: + """Parse a key-value pair from an inline dict.""" + key, _, value_str = pair_str.partition("=") + key = key.strip().strip('"').strip("'") + value_str = value_str.strip() + return key, self._parse_value(value_str) + + def _parse_array(self, array_str: str) -> list[Any]: + """Parse a TOML array.""" + array_str = array_str.strip() + if not array_str: + return [] + + elements = [] + current = "" + depth = 0 + in_string = False + string_char = None + + for char in array_str: + if char in ('"', "'") and not in_string: + in_string = True + string_char = char + current += char + elif char == string_char and in_string: + in_string = False + string_char = None + current += char + elif char == "[" and not in_string: + depth += 1 + current += char + elif char == "]" and not in_string: + depth -= 1 + current += char + elif char == "{" and not in_string: + depth += 1 + current += char + elif char == "}" and not in_string: + depth -= 1 + current += char + elif char == "," and depth == 0 and not in_string: + if current.strip(): + elements.append(self._parse_value(current.strip())) + current = "" + else: + current += char + + if current.strip(): + elements.append(self._parse_value(current.strip())) + + return elements + + +def loads(text: str) -> dict[str, Any]: + """Parse TOML text and return a dictionary.""" + reader = TomlReader(text) + return reader.parse() + + +def load(path: str) -> dict[str, Any]: + """Read the contents of a TOML file and parse them.""" + with open(path) as f: + content = f.read() + + return loads(content) + + +# Example usage +if __name__ == "__main__": + toml_text = """# This is the root comment +# It spans multiple lines + +# Database configuration +[database] +host = "localhost" +port = 5432 + +# Connection settings +timeout = 30 +config = {retry = 3, delay = 100, } + +[server] +# Server address +address = "0.0.0.0" +ports = [8080, 8081, 8082, ] +multi_line = [ + 1, 2, 3, + 4, 5, 6, +] + +# Nested config using dotted keys +[server.logging] +server.logging.level = "info" +server.logging.file = "/var/log/app.log" + +[numbers] +hex = 0xDEADBEEF +binary = 0b11010110 +with_underscores = 1_000_000 +special = inf +neg_inf = -inf +not_a_num = nan + +[strings] +basic = "Hello\\nWorld" +literal = 'C:\\Users\\path' +unicode = "Emoji: \\u2764" +multiline = \""" +This is a +multi-line string\""" +multiline_literal = ''' +No escapes here: \\n stays literal''' + +[nested] +inline = { + name = "prod", + settings = {debug = false, level = 5, }, +} + +[types] +# Type annotations - values are set to the type itself +my_int_type = int +my_float_type = float +my_str_type = str +my_bool_type = bool +my_list_type = list +my_dict_type = dict +""" + import json + + result = loads(toml_text) + print(json.dumps(result, indent=2, default=str)) diff --git a/src/lib/better_launch/better_launch/tui/__init__.py b/src/lib/better_launch/better_launch/tui/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/lib/better_launch/better_launch/tui/better_tui.py b/src/lib/better_launch/better_launch/tui/better_tui.py new file mode 100644 index 0000000000..a0ebb0fa5c --- /dev/null +++ b/src/lib/better_launch/better_launch/tui/better_tui.py @@ -0,0 +1,630 @@ +import os +from typing import Literal, Callable +from enum import IntEnum, auto +import logging +import threading +import re +from dataclasses import dataclass + +from prompt_toolkit import Application, print_formatted_text +from prompt_toolkit.output.color_depth import ColorDepth +from prompt_toolkit.cursor_shapes import CursorShape +from prompt_toolkit.application.current import get_app +from prompt_toolkit.layout.containers import HSplit, Window, ConditionalContainer +from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout.layout import Layout +from prompt_toolkit.filters import Condition +from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent +from prompt_toolkit.widgets import TextArea +from prompt_toolkit.formatted_text import ANSI +from prompt_toolkit.history import InMemoryHistory +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.patch_stdout import patch_stdout +from prompt_toolkit.shortcuts import set_title + +from better_launch import BetterLaunch +from better_launch.elements import ( + AbstractNode, + ForeignNode, + Component as ComponentNode, + LifecycleStage, +) +import better_launch.ros.logging as roslog + +from better_launch.tui.footer_menu import FooterMenu + + +class AppMode(IntEnum): + """States of our TUI. The TUI decides what to display based on the active state. + """ + # See BetterTui._switch_mode for details + STANDARD = auto() + CONFIRM_EXIT = auto() + SEARCH_NODE = auto() + NODE_MENU = auto() + NODE_INFO = auto() + NODE_LIFECYCLE = auto() + CONFIRM_NODE_TAKEOVER = auto() + CONFIRM_NODE_RESTART = auto() + CONFIRM_NODE_KILL = auto() + LOG_LEVEL = auto() + NODE_LOG_LEVEL = auto() + + +@dataclass +class LogLevel: + name: str + level: int + style: str + + +_log_levels = { + "INFO": LogLevel("INFO", logging.INFO, "ansibrightgreen"), + "WARNING": LogLevel("WARNING", logging.WARNING, "yellow"), + "ERROR": LogLevel("ERROR", logging.ERROR, "ansibrightred"), + "CRITICAL": LogLevel("CRITICAL", logging.CRITICAL, "ansibrightmagenta"), + "DEBUG": LogLevel("DEBUG", logging.DEBUG, "ansibrightblue"), + "MUTE": LogLevel("MUTE", 999, "grey"), +} + + +class NodeLogFilter(logging.Filter): + def __init__(self, name: str = ""): + super().__init__(name) + self.muted: set[str] = set() + self.hermit: str = None + + def mute(self, node: str) -> None: + self.muted.add(node) + + def unmute(self, node: str) -> None: + self.muted.discard(node) + + def set_hermit(self, node: str) -> None: + self.hermit = node + + def clear(self) -> None: + self.muted.clear() + self.hermit = None + + def filter(self, record: logging.LogRecord) -> bool: + if self.hermit: + if record.name != self.hermit: + return False + + elif record.name in self.muted: + return False + + return super().filter(record) + + +# Custom level to block all output +logging.addLevelName(999, "MUTE") + + +class BetterTui: + def __init__( + self, + launch_func: Callable[[], None], + *, + manage_foreign_nodes: bool = False, + keep_alive: bool = False, + color_depth: Literal[1, 4, 8, 24] = 8, + ): + """Our TUI class. Use [run][] to start the TUI. + + In order to override keybindings you may set the BL_TUI_KEYBINDS environment variable. For example, this will bind the nodes menu to ctrl-n and setting the log level to ctrl-l: + + .. code:: bash + + BL_TUI_KEYBINDS="nodes: c-n; loglevel: c-l" bl better_launch 02_ui.launch.py + + The syntax is always `:` separated by `;`. The valid actions are `exit`, `mute`, `nodes`, `loglevel`, `cancel`, `enter`, `next`, `previous`. For valid key specifiers see `prompt_toolkit `_. Take special note of how the Alt/Meta/Option key is treated. + + Parameters + ---------- + launch_func : Callable[[], None] + The launch function to run once this TUI is started. + manage_foreign_nodes : bool, optional + Whether to list foreign nodes in the nodes menu. + keep_alive : bool, optional + If True, the TUI will keep running even when the last node stops running. + color_depth : Literal[1, 4, 8, 24], optional + How many colors to use for display. Corresponds to monochrome, ANSI colors, 256 colors, true colors. + """ + self.launch_func = launch_func + self.manage_foreign_nodes = manage_foreign_nodes + self.keep_alive = keep_alive + self.color_depth = { + 1: ColorDepth.DEPTH_1_BIT, + 4: ColorDepth.DEPTH_4_BIT, + 8: ColorDepth.DEPTH_8_BIT, + 24: ColorDepth.DEPTH_24_BIT, + }[color_depth] + + self.history = InMemoryHistory() + self.bindings = KeyBindings() + + # Allows the user to override keybindings. Keybindings as lists to support meta keys. + # See https://python-prompt-toolkit.readthedocs.io/en/stable/pages/advanced_topics/key_bindings.html + self.keybinds = { + "exit": ["c-c"], + "mute": ["space"], + "nodes": ["tab"], + "loglevel": ["x"], + "cancel": ["escape"], + "enter": ["enter"], + "next": ["tab"], + "previous": ["s-tab"], + } + + # Keybind overrides + alt_keybinds = os.environ.get("BL_TUI_KEYBINDS") + if alt_keybinds: + for item in alt_keybinds.split(";"): + item = item.strip() + if not re.match(r"[a-z_]+:\s*[\w ]+", item): + raise ValueError(f"Invalid keybind override {item}") + + action, keys = item.split(":", maxsplit=1) + if action not in self.keybinds: + logging.getLogger().warning(f"Ignoring unknown keybind {action}") + + self.keybinds[action] = keys.strip().split(" ") + + # Make the key combinations in our footer show up a little nicer + default_footer_text = " \x1b[38;5;208m{exit}\x1b[0m Quit \x1b[38;5;208m{mute}\x1b[0m Mute \x1b[38;5;208m{nodes}\x1b[0m Nodes \x1b[38;5;208m{loglevel}\x1b[0m Log Level" + + keys_print = {} + for action, keys in self.keybinds.items(): + # We only show the first key (or two in case of meta + key, see below) + k = keys[0] + + # Control as ^X + if k.startswith("c-"): + k = "^" + k[2].upper() + k[3:] + # Meta as M- + elif k == "escape" and len(keys) > 1: + # Represented as escape + a key stroke in prompt_toolkit + k = "M-" + keys[1][0].upper() + keys[1][1:] + else: + k = k[0].upper() + k[1:] + + keys_print[action] = k + + self.default_footer_text = ANSI(default_footer_text.format(**keys_print)) + + self.mode = AppMode.STANDARD + self.footer_text: str = self.default_footer_text + self.nodes_snapshot: list[AbstractNode] = [] + self.selected_node: AbstractNode = None + + self.prev_log_level = _log_levels["MUTE"] + self.log_level = _log_levels["INFO"] + self.muted = False + + self.title: FormattedTextControl = None + self.footer_window: Window = None + self.search_field: TextArea = None + self.search_buffer: Buffer = None + self.footer_menu: FooterMenu = None + + # Allows us to have more control over what is showing + self.log_filter = NodeLogFilter() + + self._setup_key_bindings() + + def run(self): + """Initialize the TUI app, then run the launch function before starting the TUI. + """ + layout = self._make_layout() + app = Application( + layout=layout, + key_bindings=self.bindings, + full_screen=False, + color_depth=self.color_depth, + cursor=CursorShape.BLOCK, + ) + + def _run_launch_func() -> None: + """Runs the launch function passed to the constructor and sets up the log capturing mechanism.""" + self.launch_func() + + # Doing this earlier will mess up screen output somehow + log_handler: logging.Handler = roslog.launch_config.get_screen_handler() + log_handler.addFilter(self.log_filter) + + bl = BetterLaunch.wait_for_instance() + set_title(os.path.basename(bl.launchfile)) + bl.spin(exit_with_last_node=not self.keep_alive) + self.quit("launch function exited") + + launch_thread = threading.Thread(target=_run_launch_func) + + with patch_stdout(raw=True): + launch_thread.start() + app.run() + + def quit(self, reason: str) -> None: + """Shutdown better_launch if it is still running, then exit the TUI. + + Parameters + ---------- + reason : str + A reason for the shutdown that will be shown to the user. + """ + bl = BetterLaunch.instance() + if bl: + try: + bl.shutdown(reason) + except Exception as e: + print(e) + + try: + get_app().exit() + except Exception: + # Might already have exited + pass + + # Some common helpers + def _is_footer_visible(self) -> bool: + """Whether the footer line should be visible. + """ + return self.mode not in (AppMode.SEARCH_NODE,) + + def _is_search_visible(self) -> bool: + """Whether the search bar should be visible. + """ + return self.mode in (AppMode.SEARCH_NODE,) + + def _is_menu_visible(self) -> bool: + """Whether the menu widget should be visible. + """ + return self.mode not in ( + AppMode.STANDARD, + AppMode.NODE_INFO, + ) + + def _get_matching_node_items(self, filter: str) -> list[tuple[str, str, AbstractNode]]: + """Check which node items in the current node snapshot match the provided filter string. + + Parameters + ---------- + filter : str + A string that will be looked for in each nodes' full name. + + Returns + ------- + list[tuple[str, str, AbstractNode]] + The style, short name, and node for each node that matched the filter string. + """ + filter = filter.lower() + ret = [] + + for n in self.nodes_snapshot: + if filter in n.fullname.lower(): + style = "green" if n.is_running else "red" + ret.append((style, n.name, n)) + + return ret + + def _set_log_level(self, level: LogLevel) -> None: + """Configure the severity level that our logger will output on the terminal. + + Parameters + ---------- + level : LogLevel + The new minimum severity level. + """ + self.prev_log_level = self.log_level + self.log_level = level + handler: logging.Handler = roslog.launch_config.get_screen_handler() + handler.setLevel(level.level) + + def _menu_cancel(self) -> None: + """Hide any open menu by returning to STANDARD mode. + """ + self._switch_mode(AppMode.STANDARD) + get_app().layout.focus(self.footer_window) + + # Setup user interactions + def _setup_key_bindings(self) -> None: + """Setup the key bindings. This is how the user will interact with the TUI and usually results in calls to [_switch_mode` or :meth:`_handle_menu_accept][]. + """ + bind = self.bindings.add + + mode_standard = Condition(lambda: self.mode == AppMode.STANDARD) + menu_visible = Condition(self._is_menu_visible) + + + @bind(*self.keybinds["exit"]) + async def _(event: KeyPressEvent): + self._switch_mode(AppMode.CONFIRM_EXIT) + + @bind(*self.keybinds["mute"], filter=~Condition(self._is_search_visible)) + def _(event: KeyPressEvent): + self.muted = not self.muted + level = _log_levels["MUTE"] if self.muted else self.prev_log_level + self._set_log_level(level) + + @bind(*self.keybinds["nodes"], filter=mode_standard) + def _(event: KeyPressEvent): + self._switch_mode(AppMode.SEARCH_NODE) + + @bind(*self.keybinds["loglevel"], filter=mode_standard) + def _(event: KeyPressEvent): + self._switch_mode(AppMode.LOG_LEVEL) + + # Menu interactions + @bind(*self.keybinds["cancel"], filter=menu_visible, eager=True) + def _(event: KeyPressEvent): + self._switch_mode(AppMode.STANDARD) + + @bind(*self.keybinds["enter"], filter=menu_visible) + def _(event: KeyPressEvent): + if not self.footer_menu.items: + self._menu_cancel() + else: + self._handle_menu_accept(self.footer_menu.selected) + + @bind(*self.keybinds["next"], filter=menu_visible) + def _(event: KeyPressEvent): + self.footer_menu.select_next() + + @bind(*self.keybinds["previous"], filter=menu_visible) + def _(event: KeyPressEvent): + self.footer_menu.select_prev() + + for i in range(10): + + @bind( + str(i), + filter=menu_visible + & ~Condition(self._is_search_visible) + & Condition(lambda k=i: k < len(self.footer_menu.items)), + ) + def _(event: KeyPressEvent): + self.footer_menu.select((i - 1) % 10) + self._handle_menu_accept(self.footer_menu.selected) + + def _switch_mode(self, mode: AppMode) -> None: + """The TUI basically uses a statemachine to decide what to show in the bottom menu. This function transitions the TUI into a new state. + + Parameters + ---------- + mode : AppMode + The state to transition to. + """ + self.mode = mode + + if mode == AppMode.STANDARD: + self.selected_node = None + self.footer_text = self.default_footer_text + + elif mode == AppMode.CONFIRM_EXIT: + self.footer_text = "Shutdown nodes and quit?" + self.footer_menu.set_items(["yes", "no"]) + + elif mode == AppMode.SEARCH_NODE: + self.footer_text = "" + + bl = BetterLaunch.instance() + self.nodes_snapshot = bl.get_nodes( + include_components=True, + include_launch_service=True, + include_foreign=self.manage_foreign_nodes, + ) + + items = self._get_matching_node_items("") + self.footer_menu.set_items(items) + + self.search_buffer.text = "" + get_app().layout.focus(self.search_field) + + elif mode == AppMode.NODE_MENU: + # Contains the format, node name, and a reference to the AbstractNode + # (see SEARCH_NODE above) + item = self.footer_menu.get_selected_item() + node = item[2] + self.selected_node = node + + choices = ["info", "log level"] + if node.is_running: + if node.is_lifecycle_node(): + choices.append("lifecycle") + + if isinstance(node, ForeignNode): + choices.append("takeover") + elif isinstance(node, ComponentNode): + choices.extend(["restart", "unload"]) + else: + choices.extend(["restart", "kill"]) + else: + choices.append("start") + + self.footer_text = node.fullname + self.footer_menu.set_items(choices) + + elif mode == AppMode.NODE_INFO: + # Simply print to our captured stdout + cols = get_app().output.get_size().columns + bar = "\n" + "=" * cols + "\n" + # NOTE we should not use html formatting as some node params may be html-like + text = self.selected_node.get_info_sheet() + print_formatted_text(bar, "\n", ANSI(text), bar) + + self._menu_cancel() + + elif mode == AppMode.NODE_LIFECYCLE: + self.footer_text = "Choose target state for " + self.selected_node.fullname + + valid_stages = list([s.name for s in LifecycleStage]) + active = valid_stages.index(self.selected_node.lifecycle.current_stage.name) + self.footer_menu.set_items(valid_stages, active) + + elif mode == AppMode.CONFIRM_NODE_TAKEOVER: + self.footer_text = f"Restart {self.selected_node.fullname} for takeover?" + self.footer_menu.set_items(["yes", "no"]) + + elif mode == AppMode.CONFIRM_NODE_RESTART: + self.footer_text = f"Restart {self.selected_node.fullname}?" + self.footer_menu.set_items(["yes", "no"]) + + elif mode == AppMode.CONFIRM_NODE_KILL: + self.footer_text = f"Terminate {self.selected_node.fullname}?" + self.footer_menu.set_items(["yes", "no"]) + + elif mode == AppMode.LOG_LEVEL: + self.footer_text = "Select log level" + levels = list(_log_levels.values()) + items = [(lev.style, lev.name) for lev in levels] + active = levels.index(self.log_level) + self.footer_menu.set_items(items, active) + + elif mode == AppMode.NODE_LOG_LEVEL: + self.footer_text = f"Node {self.selected_node.fullname}" + self.footer_menu.set_items(["mute", "mute others", "unmute", "unmute all"]) + + def _handle_menu_accept(self, idx: int) -> None: + """Decide what to do when a menu item is activated by the user. Usually this will result in a state transition (via [_switch_mode][]) and some side effects. + + Parameters + ---------- + idx : int + Index of the activated menu item. + """ + item = self.footer_menu.get_selected_item() + + if self.mode == AppMode.CONFIRM_EXIT: + if item == "yes": + self.quit("user request") + return + + self._menu_cancel() + + elif self.mode == AppMode.SEARCH_NODE: + self._switch_mode(AppMode.NODE_MENU) + # Don't cancel the menu here + + elif self.mode == AppMode.NODE_MENU: + action = self.footer_menu.get_selected_item() + + if action == "info": + self._switch_mode(AppMode.NODE_INFO) + + if action == "log level": + self._switch_mode(AppMode.NODE_LOG_LEVEL) + + elif action == "lifecycle": + self._switch_mode(AppMode.NODE_LIFECYCLE) + + elif action == "takeover": + self._switch_mode(AppMode.CONFIRM_NODE_TAKEOVER) + + elif action == "restart": + self._switch_mode(AppMode.CONFIRM_NODE_RESTART) + + elif action in ("kill", "unload"): + self._switch_mode(AppMode.CONFIRM_NODE_KILL) + + elif action == "start": + # No confirmation needed here + self.selected_node.start() + self._menu_cancel() + + else: + self._menu_cancel() + + elif self.mode == AppMode.NODE_LIFECYCLE: + target_stage = LifecycleStage[item] + self.selected_node.lifecycle.transition(target_stage) + self._menu_cancel() + + elif self.mode == AppMode.CONFIRM_NODE_TAKEOVER: + if item == "yes": + self.selected_node.takeover(kill_after=3.0) + self._menu_cancel() + + elif self.mode == AppMode.CONFIRM_NODE_RESTART: + if item == "yes": + self.selected_node.shutdown("restarting node", timeout=None) + self.selected_node.start() + self._menu_cancel() + + elif self.mode == AppMode.CONFIRM_NODE_KILL: + if item == "yes": + self.selected_node.shutdown("terminated by user") + self._menu_cancel() + + elif self.mode == AppMode.LOG_LEVEL: + if isinstance(item, tuple): + item = item[1] + + level = _log_levels[item] + self._set_log_level(level) + self._menu_cancel() + + elif self.mode == AppMode.NODE_LOG_LEVEL: + node = self.selected_node.fullname + + if item == "mute": + self.log_filter.mute(node) + elif item == "mute others": + self.log_filter.set_hermit(node) + elif item == "unmute": + self.log_filter.unmute(node) + elif item == "unmute all": + self.log_filter.clear() + self._menu_cancel() + + def _make_layout(self) -> Layout: + """Creates our TUI's layout. Even though we run in "stdout mode", the lines we occupy still count as widgets. + + Returns + ------- + Layout + The layout to be used by the app. + """ + + def on_search_update(_) -> None: + new_text = self.search_buffer.text + matches = self._get_matching_node_items(new_text) + self.footer_menu.update_items(matches) + + self.title = FormattedTextControl("") + self.footer_window = Window(FormattedTextControl(lambda: self.footer_text)) + + self.search_field = TextArea( + prompt="Search: ", + height=1, + multiline=False, + wrap_lines=False, + ) + self.search_buffer = self.search_field.buffer + self.search_buffer.on_text_changed += on_search_update + + self.footer_menu = FooterMenu([]) + + footer_visible = Condition(self._is_footer_visible) + search_visible = Condition(self._is_search_visible) + menu_visible = Condition(self._is_menu_visible) + + return Layout( + HSplit( + [ + Window(self.title, height=1), + ConditionalContainer( + self.footer_window, + footer_visible, + ), + ConditionalContainer( + self.search_field, + search_visible, + ), + ConditionalContainer( + Window(self.footer_menu, height=1), + menu_visible, + ), + ] + ) + ) diff --git a/src/lib/better_launch/better_launch/tui/footer_menu.py b/src/lib/better_launch/better_launch/tui/footer_menu.py new file mode 100644 index 0000000000..4ea5389784 --- /dev/null +++ b/src/lib/better_launch/better_launch/tui/footer_menu.py @@ -0,0 +1,158 @@ +from typing import Any +from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.application.current import get_app + + +class FooterMenu(FormattedTextControl): + def __init__( + self, + items: list[str | tuple] = None, + *, + empty_prompt: str = "---", + ellipses: str = "…", + highlight_style: str = "reverse", + ): + """Creates a single line of selectable items. This allows to have menus without needing more space for drawing submenus. Items that don't fit into the line will be "scrolled" to as the selection shifts. + + Parameters + ---------- + items : list[str | tuple[str]], optional + The items to display. Items can be strings or tuples but don't have to be consistent. If an item is a tuple, the first element is the style of the item and the second element its label. Additional elements are ignored and can be used to store additional information with each item. + empty_prompt : str, optional + What to show when there are no items. + ellipses : str, optional + A character or string to signify that there are additional items. + highlight_style : str, optional + The style to use for showing that an item is selected. + """ + super().__init__(self.render, focusable=False) + + self.items = items or [] + self.selected = 0 + self.empty_prompt = empty_prompt + self.ellipses = ellipses + self.highlight_style = highlight_style + + def render(self): + if not self.items: + return "---" + + cols = get_app().output.get_size().columns + segments = [] + + for i, txt in enumerate(self.items): + if isinstance(txt, tuple): + style, txt, *_ = txt + else: + style = "" + + if i == self.selected: + style += " " + self.highlight_style + + segments.append((style, f" {txt} ")) + + # fits? + if sum(len(s[1]) for s in segments) <= cols: + return segments + + ell = ("", self.ellipses) + budget = cols - 2 * len(ell[1]) + shown = [segments[self.selected]] + used = len(segments[self.selected][1]) + left, right = self.selected - 1, self.selected + 1 + + while True: + added = False + if left >= 0 and used + len(segments[left][1]) <= budget: + shown.insert(0, segments[left]) + used += len(segments[left][1]) + left -= 1 + added = True + + if right < len(segments) and used + len(segments[right][1]) <= budget: + shown.append(segments[right]) + used += len(segments[right][1]) + right += 1 + added = True + + if not added: + break + + if left >= 0: + shown.insert(0, ell) + + if right < len(segments): + shown.append(ell) + + return shown + + def select_next(self) -> None: + """Select the next menu item, wrapping around. + """ + self.selected = (self.selected + 1) % len(self.items) + get_app().invalidate() + + def select_prev(self) -> None: + """Select the previous menu item, wrapping around. + """ + self.selected = (self.selected - 1) % len(self.items) + get_app().invalidate() + + def select(self, idx: int) -> None: + """Select the specified existing menu item. No modulo operation is performed on the passed index, so it must be valid. + + Parameters + ---------- + idx : int + The index of the item to be selected. + + Raises + ------ + ValueError + If the provided index is outside the range of items in this menu. + """ + if not 0 <= idx < len(self.items): + raise ValueError( + f"Item index out of range (idx={idx}, len={len(self.items)})" + ) + + self.selected = idx + get_app().invalidate() + + def get_selected_item(self) -> str | tuple: + """Return the currently selected item data. If the item is a tuple it will have formatting in the first slot, its string representation in the second, and any additional information in subsequent slots. + + Returns + ------- + str | tuple + The item as it was passed to this menu. + """ + return self.items[self.selected] + + def set_items(self, items: list[str | tuple[str, str]], default: int = 0) -> None: + """Replace the items currently in this menu. + + Parameters + ---------- + items : list[str | tuple[str, str]] + The new items to use. If an item is a tuple, its first slot must be a formatting string and the second its string information. Any subsequent slots are kept, but ignored. + default : int, optional + Index of the item to select. + """ + self.items = items + self.selected = default + get_app().invalidate() + + def update_items(self, items: list[str | tuple[str, str]]) -> None: + """Like [FooterMenu.set_items][], except that it keeps the currently selected index intact if it is contained within the new list of items. + + Parameters + ---------- + items : list[str | tuple[str, str]] + The new items to use. If an item is a tuple, its first slot must be a formatting string and the second its string information. Any subsequent slots are kept, but ignored. + """ + if self.selected >= len(items): + self.selected = 0 + + self.items = items + get_app().invalidate() diff --git a/src/lib/better_launch/better_launch/utils/__init__.py b/src/lib/better_launch/better_launch/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/lib/better_launch/better_launch/utils/better_logging.py b/src/lib/better_launch/better_launch/utils/better_logging.py new file mode 100644 index 0000000000..831e851667 --- /dev/null +++ b/src/lib/better_launch/better_launch/utils/better_logging.py @@ -0,0 +1,399 @@ +from typing import Any, Callable, Iterable +import os +import re +import logging +from datetime import datetime +import enum + +import better_launch.ros.logging as roslog +from .colors import get_contrast_color +from .settings import Colormode, Settings + + +# Log format string for ROS so that we can identify and reformat its log messages. +ROSLOG_PATTERN_ROS = "%%{severity}%%{time}%%{message}" + +# Regular expression matching ROSLOG_PATTERN_ROS. The named groups will be matched to +# logging.LogRecord attributes via their group names. +ROSLOG_PATTERN_BL = r"%%(?P\w+)%%(?P[\d.]+)%%(?P[\s\S]*)" + + +class LogSink(enum.IntEnum): + SCREEN = 0 + LOG = 1 + OWN_LOG = 2 + NONE = 3 + + +default_log_colormap = { + # 0m: resets all colors and attributes. + # 20m: resets only attributes (underline, etc.), leaving colors unchanged. + # 39m: resets only foreground color, leaving attributes unchanged. + # 49m: resets only background color, leaving attributes unchanged. + "INFO": "\x1b[92;20m", + "WARN": "\x1b[93;20m", + "WARNING": "\x1b[93;20m", + "ERROR": "\x1b[91;20m", + "CRITICAL": "\x1b[20;1;95m", + "FATAL": "\x1b[20;1;91m", + "DEBUG": "\x1b[36;20m", +} + + +# A nice amber for all logging sources +default_source_color = 222 + + +# Taken from ROS2's logging.handlers. Since that module replaces itself this function is not +# accessible from the outside. +def _with_per_logger_formatting(cls): + """Add per logger formatting capabilities to the given logging.Handler.""" + + class _trait(cls): + """A logging.Handler subclass to enable per logger formatting.""" + + def __init__(self, *args, **kwargs): + super(_trait, self).__init__(*args, **kwargs) + self._formatters = {} + + def setFormatterFor(self, logger, formatter): + """Set formatter for a given logger instance or logger name.""" + logger_name = logger if isinstance(logger, str) else logger.name + self._formatters[logger_name] = formatter + + def unsetFormatterFor(self, logger): + """Unset formatter for a given logger instance or logger name, if any.""" + logger_name = logger if isinstance(logger, str) else logger.name + if logger_name in self._formatters: + del self._formatters[logger_name] + + def format(self, record): # noqa + if record.name in self._formatters: + formatter = self._formatters[record.name] + return formatter.format(record) + return super(_trait, self).format(record) + + _trait.__name__ = cls.__name__ + _trait.__doc__ = cls.__doc__ + return _trait + + +class PrettyLogFormatter(logging.Formatter): + def __init__( + self, + format: str, + timestamp_format: str = "%Y-%m-%d %H:%M:%S.%f", + *, + defaults: dict[str, Any] = None, + roslog_pattern: str = None, + source_colors: ( + str | int | Iterable[int] | dict[str, Any] + ) = default_source_color, + log_colors: str | int | Iterable[int] | dict[str, Any] = None, + no_colors: bool = False, + max_message_length: int = 0, + ): + """A specialized formatter that will try to extract various details from messages logged by ROS2 nodes and reformat them. + + The following additional keys can be used for formatting messages: + * {sourcecolor_start}: colors subsequent characters based on the message's source until a {sourcecolor_end} is encountered. + * {levelcolor_start}: colors subsequent characters based on the message's severity until a {levelcolor_end} is encountered. + + Colors can be specified in 3 ways: + * an ANSI/VT100 escape sequence, typically starting with `\\e` or `\\x1b`. + * an integer, referring to a color from the *vte* 256 colors table. + * a tuple of 3 integers representing an RGB value. + + Parameters + ---------- + format : str, optional + The format this instance will use when formatting messages. + timestamp_format : _type_, optional + The format to use for timestamps (we like human readable here). + defaults : dict[str, Any], optional + Defaults the formatter may use when formatting strings. + roslog_pattern : str, optional + The pattern used for matching incoming log messages. The pattern should define named groups that will be matched to logging.Record attributes via their names. + source_colors : str | int | Iterable[int] | dict[str, Any], optional + Colors to use when formatting `sourcecolor_start` tags based on the source of the log report. If a string, integer or iterable, use this as the color for all sources. If None, use a different color for every source. Pass a dict to specify custom colors for a set of sources. + log_colors : str | int | Iterable[int] | dict[str, Any], optional + Colors to use when formatting `levelcolor_start` tags based on the log report's severity. If a string, integer or iterable, use this as the color for all sources. If None, use the default log colors from `default_log_colormap`. Pass a dict to override the colors for select log levels identified by name. + no_colors: bool, optional + If True, all colors will be disabled. + max_message_length : int, optional + Limit the length of the message content formatted by this logger. No limits are imposed if <= 0. + """ + super().__init__(format, timestamp_format, "{", True, defaults=defaults) + + self.converter = datetime.fromtimestamp + + if not roslog_pattern: + roslog_pattern = ROSLOG_PATTERN_BL + self.roslog_pattern = re.compile(roslog_pattern) + + self.source_colors = {} + if isinstance(source_colors, dict): + self.source_colors.update(source_colors) + elif source_colors is not None: + self.source_colors["*"] = source_colors + + self.log_colors = dict(default_log_colormap) + if isinstance(log_colors, dict): + self.log_colors.update(log_colors) + elif log_colors is not None: + self.log_colors["*"] = log_colors + + if no_colors: + self.source_colors["*"] = "" + self.log_colors["*"] = "" + + self.max_message_length = max_message_length + + def get_source_color(self, source: str) -> str: + """Return the color associated with the provided source.""" + if "*" in self.source_colors: + source = "*" + elif source != "*" and source not in self.source_colors: + self.source_colors[source] = get_contrast_color() + + color = self.source_colors.get(source, "") + color_start = self.format_color(color) + color_end = "\x1b[0m" if color_start else "" + + return color_start, color_end + + def get_loglevel_color(self, level: int | str) -> tuple[str, str]: + if "*" in self.log_colors: + level = "*" + elif isinstance(level, int): + level: str = logging.getLevelName(level) + + color = self.log_colors.get(level, "") + color_start = self.format_color(color) + color_end = "\x1b[0m" if color_start else "" + + return color_start, color_end + + def format_color(self, color: Any) -> str: + if isinstance(color, str): + return color + + if isinstance(color, int): + return f"\x1b[38;5;{color}m" + + if isinstance(color, (tuple, list)): + return f"\x1b[38;2;{color[0]};{color[1]};{color[2]}m" + + return "" + + def formatTime(self, record: logging.LogRecord, datefmt: str = None) -> str | float: + try: + dt: datetime = self.converter(record.created) + if datefmt: + return dt.strftime(datefmt) + return dt.strftime(self.default_time_format) + except Exception: + return record.created + + def format(self, record: logging.LogRecord) -> str: + match = self.roslog_pattern.match(record.msg) + + if match: + for key, val in match.groupdict().items(): + # If we replace an existing key, make sure the value type matches the original, + # otherwise we will get some problems with formatting down the line + key_type = type(getattr(record, key, "")) + val = key_type(match.group(key)) + setattr(record, key, val) + + if "levelname" in match.groupdict() and "levelno" not in match.groupdict(): + record.levelno = logging.getLevelName(record.levelname) + + elif ( + "levelno" in match.groupdict() and "levelname" not in match.groupdict() + ): + record.levelname = logging.getLevelName(record.levelno) + + record.sourcecolor_start, record.sourcecolor_end = self.get_source_color( + record.name + ) + record.levelcolor_start, record.levelcolor_end = self.get_loglevel_color( + record.levelno + ) + + msg = record.getMessage() + if self.max_message_length > 0 and len(msg) > self.max_message_length: + msg = msg[: self.max_message_length] + "..." + record.msg = msg + # The message has already been formatted with its arguments above. + # Clearing record.args prevents the next formatter from attempting + # a second '%' substitution on the truncated text, which could crash + # if any format placeholders were removed during truncation. + record.args = None + return super().format(record) + + +class RecordForwarder(logging.Handler): + def __init__(self, formatter, level: int = logging.INFO): + """A log handler that forwards any records it receives to callbacks. + + Parameters + ---------- + level : int, optional + The minimum logging level this handler accepts. + """ + super().__init__(level) + self.formatter = formatter + self.listeners = [] + + def add_listener(self, callback: Callable[[logging.LogRecord], None]): + self.listeners.append(callback) + + def emit(self, record): + # The formatter will extract information like levelname and set it on the record + self.format(record) + for cb in self.listeners: + cb(record) + + +RecordForwarder = _with_per_logger_formatting(RecordForwarder) + + +class StubbornHandler(logging.Handler): + """This handler resists the common changes ROS2 attempts to make for its logging so that our formatters can work properly. + + The ROS2 launch system assigns the same formatter to many sources using special wrappers. However, we don't want our log forwarders and formatters to be replaced just like that. + + Parameters + ---------- + actual_handler : logging.Handler + The handler which will actually handle any incoming log records. + """ + + def __init__(self, actual_handler: logging.Handler, level: int = logging.INFO): + super().__init__(level) + self.actual_handler = actual_handler + + def setFormatterFor(self, logger, formatter): + return + + def unsetFormatterFor(self, logger): + return + + def emit(self, record): + self.actual_handler.emit(record) + + def format(self, record): + return self.actual_handler.format(record) + + +class LevelFilter(logging.Filter): + def __init__(self, level: int): + super().__init__() + self.level = level + + def filter(self, record: logging.LogRecord) -> bool: + return record.levelno >= self.level + + +def configure_logger( + logger: logging.Logger, + output: LogSink | Iterable[LogSink] | Iterable[str] | str = None, + screen_formatter: logging.Formatter = None, + file_formatter: logging.Formatter = None, +) -> None: + """Initialize the logging framework. + """ + # TODO proper docstring + if output: + if isinstance(output, Iterable) and not isinstance(output, str): + output = [output] + + for idx, sink in output: + if isinstance(sink, str): + output[idx] = LogSink[output.upper()] + + output = set(output) + else: + output = {LogSink.SCREEN} + + config = Settings() + screen_filter = LevelFilter(config.screen_log_level) + file_filter = LevelFilter(config.file_log_level) + + for sink in output: + if sink == LogSink.SCREEN: + screen_handler = roslog.launch_config.get_screen_handler() + if screen_handler not in logger.handlers: + if not screen_formatter: + screen_formatter = roslog.launch_config.screen_formatter + + screen_handler.setFormatterFor(logger, screen_formatter) + screen_handler.addFilter(screen_filter) + logger.addHandler(screen_handler) + + elif sink == LogSink.LOG: + common_log_handler = roslog.launch_config.get_log_file_handler() + if common_log_handler not in logger.handlers: + if not file_formatter: + file_formatter = roslog.launch_config.file_formatter + + common_log_handler.setFormatterFor(logger, file_formatter) + common_log_handler.addFilter(file_filter) + logger.addHandler(common_log_handler) + + elif sink == LogSink.OWN_LOG: + # Make sure the logfile is not in / + logfile = logger.name.strip("/").replace("/", os.path.sep) + ".log" + + # We use namespaces for nesting the logfile in a directory structure + os.makedirs( + os.path.join(roslog.launch_config.log_dir, os.path.dirname(logfile)), + exist_ok=True, + ) + + own_log_handler = roslog.launch_config.get_log_file_handler(logfile) + own_log_handler.addFilter(file_filter) + if own_log_handler not in logger.handlers: + if not file_formatter: + file_formatter = roslog.launch_config.file_formatter + + own_log_handler.setFormatterFor(logger, file_formatter) + logger.addHandler(own_log_handler) + + +def init_logging( + log_config: roslog.LaunchConfig, +) -> None: + config = Settings() + if config.colormode == Colormode.DEFAULT: + src_color = default_source_color + log_color = None + elif config.colormode == Colormode.SEVERITY: + src_color = "" + log_color = None + elif config.colormode == Colormode.SOURCE: + src_color = None + log_color = "" + elif config.colormode == Colormode.NONE: + src_color = "" + log_color = "" + elif config.colormode == Colormode.RAINBOW: + src_color = None + log_color = None + else: + raise ValueError(f"Invalid colormode {config.colormode}") + + # We'll handle formatting and color ourselves, just get the nodes to comply + os.environ["RCUTILS_CONSOLE_OUTPUT_FORMAT"] = ROSLOG_PATTERN_ROS + os.environ["RCUTILS_COLORIZED_OUTPUT"] = "0" + + log_config.level = logging.INFO + + log_config.screen_formatter = PrettyLogFormatter( + format=config.screen_log_format, + source_colors=src_color, + log_colors=log_color, + max_message_length=config.print_limit, + ) + log_config.file_formatter = PrettyLogFormatter(format=config.file_log_format) diff --git a/src/lib/better_launch/better_launch/utils/click.py b/src/lib/better_launch/better_launch/utils/click.py new file mode 100644 index 0000000000..b2f35a04d8 --- /dev/null +++ b/src/lib/better_launch/better_launch/utils/click.py @@ -0,0 +1,157 @@ +from typing import Any, Type, Iterable, Callable +from dataclasses import dataclass +import click + +from better_launch.utils.settings import Colormode, _update_settings + + +@dataclass +class DeclaredArg: + _undefined = object() + + name: str + ptype: Type + default: Any = _undefined + description: str = None + + +def get_click_options(declared_args: Iterable[DeclaredArg]) -> list[click.Option]: + options = [] + for arg in declared_args: + if arg.default != DeclaredArg._undefined: + default = arg.default + required = False + else: + default = None + required = True + + options.append( + click.Option( + [f"--{arg.name}"], + type=arg.ptype, + default=default, + required=required, + show_default=True, + help=arg.description, + ) + ) + + return options + + +def get_click_bl_options(expose: bool = False) -> list[click.Option]: + """Get the click options specific to better_launch itself. + + Parameters + ---------- + expose : bool, optional + If True, click will forward these options to the command callback. + + Returns + ------- + list[click.Option] + _description_ + """ + def update_value(ctx: click.Context, param: click.Parameter, value: Any): + key = param.name + if param.name.startswith(("bl_", "bl-")): + key = param.name[3:].replace("-", "_") + _update_settings(**{key: value}) + + # XXX always keep these synchronized with our Settings class + options = [ + click.Option( + ["--bl-ui"], + type=bool, + default=None, + help="Enforce or prevent starting the TUI", + expose_value=expose, # not passed to our run method + callback=update_value, + ), + click.Option( + ["--bl-colormode"], + type=click.types.Choice([c.name for c in Colormode], case_sensitive=False), + show_choices=True, + default=None, + help="Set the logging color mode", + expose_value=expose, + callback=update_value, + ), + click.Option( + ["--bl-print-limit"], + type=int, + default=None, + help="Cut off messages longer than this when printing to the terminal", + expose_value=expose, + callback=update_value, + ), + click.Option( + ["--bl-screen-log-level"], + type=click.types.Choice( + ["debug", "info", "warning", "error", "critical", "fatal"], + case_sensitive=False, + ), + show_choices=True, + default=None, + help="Only print log messages with at least this severity", + expose_value=expose, + callback=update_value, + ), + click.Option( + ["--bl-screen-log-format"], + type=str, + default=None, + help="Format used for printing log messages to the terminal", + expose_value=expose, + callback=update_value, + ), + click.Option( + ["--bl-file-log-level"], + type=click.types.Choice( + ["debug", "info", "warning", "error", "critical", "fatal"], + case_sensitive=False, + ), + show_choices=True, + default=None, + help="Only log messages with at least this severity", + expose_value=expose, + callback=update_value, + ), + click.Option( + ["--bl-file-log-format"], + type=str, + default=None, + help="Format used for writing log messages to the log file", + expose_value=expose, + callback=update_value, + ), + click.Option( + ["--use-sim-time"], + type=bool, + default=None, # NOTE important, see settings._update_settings + help="Changes the default use_sim_time setting of the root group", + expose_value=expose, + callback=update_value, + ) + ] + + return options + + +def get_click_launch_command( + cmd_name: str, + launch_func: Callable, + options: Iterable[click.Option], + cmd_help: str = None, + *, + allow_kwargs: bool = False, +) -> click.Command: + click_cmd = click.Command( + cmd_name, callback=launch_func, params=options, help=cmd_help + ) + + if allow_kwargs: + click_cmd.allow_extra_args = True + click_cmd.ignore_unknown_options = True + + return click_cmd diff --git a/src/lib/better_launch/better_launch/utils/colors.py b/src/lib/better_launch/better_launch/utils/colors.py new file mode 100644 index 0000000000..36f038513e --- /dev/null +++ b/src/lib/better_launch/better_launch/utils/colors.py @@ -0,0 +1,31 @@ +import colorsys + + +class HighContrastColorGenerator: + """Generates RGB colors with a certain distance apart so that subsequent colors are visually distinct.""" + + def __init__(self): + # Golden ratio conjugate, ensures well-spaced hues + self.hue_step = 0.61803398875 + self.hue = 0 + + def __iter__(self): + """Allows the class to be used as an iterable.""" + return self + + def __next__(self): + """Generates the next high-contrast color.""" + self.hue = (self.hue + self.hue_step) % 1 + r, g, b = colorsys.hsv_to_rgb(self.hue, 1, 1) + return (int(r * 255), int(g * 255), int(b * 255)) + + def __call__(self): + """Allows calling the instance directly to get the next color.""" + return next(self) + + +get_contrast_color = HighContrastColorGenerator() +"""A global instance to generate sequences of visually distinct colors. +""" + +# TODO define some standard colors we want to use and use them in logging and node info sheets diff --git a/src/lib/better_launch/better_launch/utils/glob_dict.py b/src/lib/better_launch/better_launch/utils/glob_dict.py new file mode 100644 index 0000000000..8049f1e2f5 --- /dev/null +++ b/src/lib/better_launch/better_launch/utils/glob_dict.py @@ -0,0 +1,328 @@ +import fnmatch + + +def glob_dict(data: dict, pattern: str, invert: bool = False, strip: bool = True) -> dict: + """Filter a nested dict by a glob pattern. + + Keys may contain slashes which are treated as path separators. + + This function supports wildcards both in dict keys and the provided pattern. * will match a single key part or skip a single pattern part. ** will consider the entire remaining branch as matching. + + **Note** that this function is destructive and will prune parts of the provided dictionary not matching pattern. Use `copy.deepcopy` if you want to keep your input dict untouched. + + Parameters + ---------- + data : dict + The input dictionary. Non-matching parts will be removed. + pattern : str + A glob-style pattern to select parts of the dictionary to keep. + invert : bool, optional + If True, only retain branches that do *not* match the provided pattern. + strip : bool, optional + If True, the matched qualifier prefix is removed from the keys of the returned dict. + + Returns + ------- + dict + The pruned input dict. + """ + if not pattern or pattern in ("*", "**"): + if invert: + return {} + return data + + def match(sub: dict, pat: list[str]) -> bool: + """Recursively filter sub to only keep keys matching pat""" + if not isinstance(sub, dict): + # Leaf, valid only if pattern was fully consumed + return not pat + + bad_keys = [] + renames = {} + + for key in list(sub.keys()): + key_parts = [p for p in key.split("/") if p] + survived, stripped_key = match_key_parts(sub, key, key_parts, pat) + + if invert: + survived = not survived + + if not survived: + bad_keys.append(key) + elif strip and stripped_key != key: + renames[key] = stripped_key + + for key in bad_keys: + del sub[key] + + # Strip the matched qualifiers if desired + for old, new in renames.items(): + sub[new] = sub.pop(old) + + # Return True if content remaining + return bool(sub) + + def match_key_parts( + sub: dict, key: str, key_parts: list[str], pat: list[str] + ) -> tuple[bool, str]: + """Match key_parts against pat, then recurse into children with remaining pattern.""" + if not pat: + # Pattern exhausted but key remains - no match + return False, key + + # Consume key_parts against pat elements one by one + remaining_key, remaining_pat = consume(key_parts, pat) + if remaining_pat is None: + return False, key + + # Pattern fully consumed: keep this node entirely + if not remaining_pat and not remaining_key: + return True, "" + + # Pattern still has elements: recurse into children + child = sub[key] + if not isinstance(child, dict): + # Can't go deeper but pattern isn't done + return False, key + + # Pat exhausted but key had extra parts (e.g. key "a/b", pattern "a"), + # this is still a match + if not remaining_pat and remaining_key: + stripped = "/".join(remaining_key) if strip else key + return True, stripped + + survived = match(child, remaining_pat) + return survived, key + + def consume(key_parts: list[str], pat: list[str]) -> tuple[list[str], list[str]]: + """Step through key_parts and pat, trying to match the two, then return what remains.""" + key_idx, pat_idx = 0, 0 + while key_idx < len(key_parts) and pat_idx < len(pat): + sub_pat = pat[pat_idx] + sub_key = key_parts[key_idx] + + if sub_pat == "**": + # Big wildcard pattern will consume the remaining key parts + return ([], pat[pat_idx + 1 :]) + elif sub_key == "*" or fnmatch.fnmatch(sub_key, sub_pat): + # fnmatch also handles the * sub-pattern + key_idx += 1 + pat_idx += 1 + elif sub_key == "**": + # Big wildcard key will consume the remaining pattern + return ([], []) + else: + # Mismatch + return (None, None) + + # Either the pattern parts or the key parts were exhausted + return (key_parts[key_idx:], pat[pat_idx:]) + + match(data, [p for p in pattern.split("/") if p]) + return data + + +def deep_merge(base: dict, override: dict) -> dict: + """Merge a (nested) override dict into a (nested) base dict. + + After this operation the entirety of override will be part of base. All values will be assigned without any copies. This operation will alter the provided base dict. Use copy.deepcopy if you don't want this. + + Parameters + ---------- + base : dict + The dict into which the override will be merged. + override : dict + The override to merge into base. + + Returns + ------- + dict + The merged dict, which is the modified base dict. + """ + result = dict(base) + + for k, v in override.items(): + if k in result and isinstance(result[k], dict) and isinstance(v, dict): + result[k] = deep_merge(result[k], v) + else: + result[k] = v + + return result + + +def merge_and_explode(*dicts: dict) -> dict: + """Recursively merge any number of nested dicts. + + All keys (including those of nested dicts) must be strings. Compound keys (e.g. "a/b") are exploded into nested dicts first. Later dicts win on key conflicts at leaf level. + + Parameters: + ----------- + dicts: dict + The dictionaries to merge. + + Returns + ------- + dict + A dict which is the result of exploding and merging all input dicts. + """ + + # TODO should also handle wildcard dicts + def explode(d: dict) -> dict: + """Expand all compound keys into nested dicts.""" + result = {} + + for key, value in d.items(): + parts = [p for p in key.split("/") if p] + child = explode(value) if isinstance(value, dict) else value + top = parts[0] + + # Build nested dict from innermost out + for part in reversed(parts[1:]): + child = {part: child} + + if ( + top in result + and isinstance(result[top], dict) + and isinstance(child, dict) + ): + result[top] = merge_and_explode(result[top], child) + else: + result[top] = child + + return result + + exploded = [explode(d) for d in dicts] + merged = exploded[0] + for nxt in exploded[1:]: + merged = deep_merge(merged, nxt) + + return merged + + +# FOR TESTING +# TODO turn into proper CI test +if __name__ == "__main__": + import copy + + data = { + "**": { + "no": "dstar", + "test": "dstar", + }, + "a": { + "b": { + "c": "hit", + "d": "excl", + }, + "x": "excl", + }, + "a/b": { + "c": "hit2", + "d": "excl", + }, + "docs": { + "readme": "hit", + "guide": "hit", + }, + "logs": { + "2024": { + "jan": "hit", + "feb": "hit", + } + }, + "other": "excl", + } + + cases = [ + ( + "a", + "get all of a, but also a/b", + { + "**": { + "no": "dstar", + "test": "dstar", + }, + "a": {"b": {"c": "hit", "d": "excl"}, "x": "excl"}, + "a/b": {"c": "hit2", "d": "excl"}, + }, + ), + ( + "a/b/c", + "exact match + sibling exclusion", + { + "**": { + "no": "dstar", + "test": "dstar", + }, + "a": {"b": {"c": "hit"}}, + "a/b": {"c": "hit2"}, + }, + ), + ( + "a/b/*", + "terminal wildcard", + { + "**": { + "no": "dstar", + "test": "dstar", + }, + "a": {"b": {"c": "hit", "d": "excl"}}, + "a/b": {"c": "hit2", "d": "excl"}, + }, + ), + ( + "logs/**", + "double-star matches all descendants", + { + "**": { + "no": "dstar", + "test": "dstar", + }, + "logs": {"2024": {"jan": "hit", "feb": "hit"}}, + }, + ), + ( + "*/b/c", + "star with specific child", + { + "a": {"b": {"c": "hit"}}, + "a/b": {"c": "hit2"}, + }, + ), + ( + "**/test", + "double-star with specific child", + { + "**": { + "test": "dstar", + } + }, + ), + ( + "z", + "only wildcards for unknown key", + { + "**": { + "no": "dstar", + "test": "dstar", + } + }, + ), + ] + + passed = 0 + failed = 0 + for pattern, desc, expected in cases: + result = glob_dict(copy.deepcopy(data), pattern) + ok = result == expected + status = "PASS" if ok else "FAIL" + print(f"[{status}] ({pattern}) {desc}") + if not ok: + print(f" expected: {expected}") + print(f" got: {result}") + failed += 1 + else: + passed += 1 + + print(f"\n{passed}/{passed + failed} passed") + print("\nmerge_dicts:", merge_and_explode(data)) diff --git a/src/lib/better_launch/better_launch/utils/introspection.py b/src/lib/better_launch/better_launch/utils/introspection.py new file mode 100644 index 0000000000..480b3ef139 --- /dev/null +++ b/src/lib/better_launch/better_launch/utils/introspection.py @@ -0,0 +1,269 @@ +from typing import Callable, Any +import sys +import threading +import ast +import inspect + + +def find_function_frame(func: Callable) -> inspect.FrameInfo: + """Find the most recent stack frame the specified function is called in. + + Note that the function **must** be part of the current stack frame that led to the invocation of this function. + + Parameters + ---------- + func : Callable + A defined function. + + Returns + ------- + inspect.FrameInfo + The frame the specified function was called from. + + Raises + ------ + ValueError + If no such frame could be found. + """ + for frame_info in inspect.stack(): + if frame_info.frame.f_code is func.__code__: + return frame_info + + raise ValueError(f"Could not find frame of function {func}") + + +def get_stack(thread_id: int = -1) -> list[inspect.FrameInfo]: + """Returns a stack of frame info for the specified thread_id. + + Parameters + ---------- + thread_id : int, optional + ID of the thread to get the frame info for. -1 will return for the caller's frame, -2 for the caller's caller, and so on. Return for the main thread if 0. + + Returns + ------- + list + A list of `inspect.FrameInfo` objects. + + Raises + ------ + IndexError + If the specified thread_id does not identify a valid frame. + """ + if thread_id < 0: + frame = sys._getframe(-thread_id) + return inspect.getouterframes(frame, 1) + + if thread_id == 0: + thread_id = threading.main_thread().ident + + frames = sys._current_frames() + return inspect.getouterframes(frames[thread_id], 1) + + +def find_calling_frame(func: Callable, thread_id: int = -1) -> inspect.FrameInfo: + """Find the most recent stack frame the specified function is called in that is NOT in the same file as the function. This is useful to e.g. identify the python file a function is imported from and called in. + + Parameters + ---------- + func : Callable + A defined function. + thread_id : int, optional + The ID of the thread to search in. Search the main thread if 0, or the current thread if -1. + + Returns + ------- + inspect.FrameInfo + A frame which called the provided function but lives in a different file than the function itself. + + Raises + ------ + ValueError + If no such frame could be found. + """ + # NOTE: be careful with this, not using the correct thread_id can cause very subtle issues. + # See https://github.com/dfki-ric/better_launch/issues/53 for details + stack = get_stack(thread_id) + func_frame = None + + for frame_info in stack: + if frame_info.frame.f_code is func.__code__: + func_frame = frame_info + + if func_frame and func_frame.filename != frame_info.filename: + return frame_info + + raise ValueError(f"Could not find the module calling {func}") + + +def get_bound_arguments(func: Callable, with_defaults: bool = True) -> dict[str, Any]: + """Retrieve the arguments that were passed to the specified function. + + Note that the function **must** be part of the current stack frame that led to the invocation of this function. + + Parameters + ---------- + func : Callable + A defined function. + with_defaults : bool + If True the returned dict will include defaults according to the function's signature for arguments that were not passed to it. + + Returns + ------- + dict[str, Any] + The arguments that were used to invoke the function. + + Raises + ------ + ValueError + If the function frame could not be identified. + """ + frame_info = find_function_frame(func) + sig = inspect.signature(func) + + relevant_keys = set(sig.parameters.keys()) + kwargs = {k: v for k, v in frame_info.frame.f_locals.items() if k in relevant_keys} + bound_args = sig.bind(**kwargs) + + if with_defaults: + bound_args.apply_defaults() + + return dict(bound_args.arguments) + + +def find_decorated_function_args(decorator_func: Callable) -> dict[str, Any]: + """Retrieve the arguments of the function that the specified decorator is wrapping. + + Note that the function **must** be part of the current stack frame that led to the invocation of this function. + + Parameters + ---------- + decorator_func : Callable + A decorator function. + + Returns + ------- + dict[str, Any] + The arguments that were used to invoke the function decorated by the provided decorator. + + Raises + ------ + ValueError + If the function could not be extracted from the decorator or the function frame could not be identified. + """ + + # Find a callable function in the decorator arguments + decorator_args = get_bound_arguments(decorator_func) + + for val in decorator_args.values(): + if inspect.isfunction(val): + return get_bound_arguments(val) + + raise ValueError("Could not determine the decorated function") + + +def find_launchthis_function(filepath: str) -> ast.FunctionDef: + """Parses a source file into an AST tree and searches for a function decorated by [better_launch.launch_this][]. + + Parameters + ---------- + filepath : str + Path to a python source file. + + Returns + ------- + ast.FunctionDef + A representation of the function decorated by launch_this, or `None` if it could not be found. + """ + try: + with open(filepath, "r", encoding="utf-8") as f: + source = f.read() + + tree = ast.parse(source) + except Exception: + return None + + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef): + continue + + # Check if function is decorated by launch_this + for decorator in node.decorator_list: + if ( + isinstance(decorator, ast.Call) and decorator.func.id == "launch_this" + ) or (isinstance(decorator, ast.Name) and decorator.id == "launch_this"): + return node + + return None + + +def get_launchfunc_signature_from_file( + filepath: str, +) -> tuple[str, inspect.Signature, str]: + """Searches for a launch function in the specified source file and returns its name, signature and docstring. + + .. seealso:: + + [find_launchthis_function][] + + Parameters + ---------- + filepath : str + Path to a python source file + + Returns + ------- + tuple[str, inspect.Signature] + The name, signature and docstring of the function decorated by launch_this. If no docstring is defined for the function it will be `None`. Likewise, if no such function could be found all parts of the returned tuple will be `None`. + """ + func_node = find_launchthis_function(filepath) + + if not func_node: + return None, None, None + + # Extract function signature + params = [] + for idx, arg in enumerate(func_node.args.args): + arg_name = arg.arg + annotation = inspect.Parameter.empty + if arg.annotation: + annotation = ast.unparse(arg.annotation) + + # Extract default value + defaults_offset = len(func_node.args.args) - len(func_node.args.defaults) + if idx >= defaults_offset: + default_node = func_node.args.defaults[idx - defaults_offset] + if isinstance(default_node, ast.Constant): + default = default_node.value + else: + default = ast.unparse(default_node) + + params.append( + inspect.Parameter( + arg_name, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + annotation=annotation, + default=default, + ) + ) + + # Handle *args + if func_node.args.vararg: + params.append( + inspect.Parameter( + func_node.args.vararg.arg, inspect.Parameter.VAR_POSITIONAL + ) + ) + + # Handle **kwargs + if func_node.args.kwarg: + params.append( + inspect.Parameter(func_node.args.kwarg.arg, inspect.Parameter.VAR_KEYWORD) + ) + + try: + doc = ast.get_docstring(func_node) + except TypeError: + doc = None + + return (func_node.name, inspect.Signature(params), doc) diff --git a/src/lib/better_launch/better_launch/utils/random_names.py b/src/lib/better_launch/better_launch/utils/random_names.py new file mode 100644 index 0000000000..6e16736d96 --- /dev/null +++ b/src/lib/better_launch/better_launch/utils/random_names.py @@ -0,0 +1,92 @@ +import random + + +class UniqueWordGenerator: + def __init__(self): + # English roots and stems + self.roots = [ + 'amber', 'arch', 'ash', 'bran', 'brass', 'bronze', 'cedar', 'crim', 'crystal', + 'dawn', 'dusk', 'ember', 'falcon', 'frost', 'gild', 'glim', 'gold', 'haven', + 'hawk', 'iron', 'mist', 'moon', 'moss', 'oak', 'pearl', 'quick', + 'raven', 'rock', 'rose', 'scar', 'shadow', 'silver', 'star', 'stone', 'storm', + 'thorn', 'thunder', 'whis', 'wind', 'winter', 'wolf', 'wood', + 'bar', 'bor', 'car', 'cor', 'dar', 'dor', 'far', 'for', 'gar', 'gor', + 'har', 'hor', 'jar', 'jor', 'kar', 'kor', 'lar', 'lor', 'mar', 'mor', + 'nar', 'nor', 'par', 'por', 'sar', 'sor', 'tar', 'tor', 'var', 'vor', + 'wal', 'wel', 'wil', 'zar', 'zel', 'bel', 'cal', 'del', 'fel', 'gel', + 'kel', 'mel', 'nel', 'pel', 'rel', 'sel', 'tel', 'vel', 'cran', 'gran' + ] + + # English suffixes and endings + self.suffixes = [ + 'ton', 'ston', 'den', 'don', 'ley', 'ly', 'ney', 'win', 'wyn', 'kin', + 'lin', 'ling', 'son', 'sen', 'man', 'mon', 'rick', 'wick', 'ford', + 'worth', 'borne', 'burn', 'by', 'dale', 'field', 'gate', 'ham', 'hold', + 'ard', 'art', 'ent', 'ant', 'er', 'or', 'ar', 'ian', 'ean', 'an', + 'ous', 'ious', 'eous', 'ace', 'ice', 'ance', 'ence', 'age', 'ine', + 'ing', 'ed', 'en', 'eth', 'ith', 'us', 'is', 'as', 'os' + ] + + # Pregenerate all possible combinations as indices + self.available_indices = list(range(len(self.roots) * len(self.suffixes))) + random.shuffle(self.available_indices) + self.next_index = 0 + + def get_unique_word(self) -> str: + """Generate the next unique word""" + if self.next_index >= len(self.available_indices): + raise ValueError("No more unique fantasy words available") + + while True: + # Get the next index and convert to root/suffix pair + idx = self.available_indices[self.next_index] + self.next_index += 1 + + root_idx = idx // len(self.suffixes) + suffix_idx = idx % len(self.suffixes) + + # Avoid some awkward combinations + word = self.roots[root_idx] + self.suffixes[suffix_idx] + if not any(x in word for x in ("shsh", "thth")): + break + + return word + + def reset(self): + """Shuffle and reset the generator""" + random.shuffle(self.available_indices) + self.next_index = 0 + + def remaining(self) -> int: + """Return number of words still available""" + return len(self.available_indices) - self.next_index + + def total_available(self) -> int: + """Return total number of unique words possible""" + return len(self.available_indices) + + +default_name_generator = UniqueWordGenerator() + + +def get_unique_word() -> str: + return default_name_generator.get_unique_word() + + +if __name__ == "__main__": + discovered = set() + samples = 10000 + collisions = 0 + + for i in range(default_name_generator.remaining()): + n = default_name_generator.get_unique_word() + + if i % 100 == 0: + print(n) + + if n in discovered: + collisions += 1 + else: + discovered.add(n) + + print(f"Collisions: {collisions}/{samples}") diff --git a/src/lib/better_launch/better_launch/utils/settings.py b/src/lib/better_launch/better_launch/utils/settings.py new file mode 100644 index 0000000000..4dc0b8d169 --- /dev/null +++ b/src/lib/better_launch/better_launch/utils/settings.py @@ -0,0 +1,194 @@ +from typing import Any +import os +import logging +from enum import IntEnum +from dataclasses import dataclass, fields, replace, asdict + + +class Colormode(IntEnum): + # Color messages based on their severity and highlight the sources in one color + DEFAULT = 0 + + # Color messages only based on their severity + SEVERITY = 1 + + # Color messages only based on the logging source + SOURCE = 2 + + # Don"t color messages + NONE = 3 + + # Give a different color to each severity and logging source + RAINBOW = 4 + + +def severity_to_loglevel(severity: str) -> int: + if not severity: + return logging.INFO + + loglevels = { + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARN": logging.WARNING, + "WARNING": logging.WARNING, + "ERROR": logging.ERROR, + "CRITICAL": logging.CRITICAL, + "FATAL": logging.FATAL, + } + return loglevels.get(severity.upper(), logging.INFO) + + +default_screen_format = "[{levelcolor_start}{levelname}{levelcolor_end}] [{sourcecolor_start}{name}{sourcecolor_end}] [{asctime}]\n{message}" + +default_file_format = "[{levelname}] [{asctime}] {message}" + + +@dataclass(frozen=True) +class _Settings: + ui: bool = False + colormode: Colormode = Colormode.DEFAULT + print_limit: int = 0 + screen_log_level: int = logging.INFO + file_log_level: int = logging.INFO + screen_log_format: str = default_screen_format + file_log_format: str = default_file_format + use_sim_time: bool = False + + def __init__(self, **kwargs): + """ + Initialize settings with priority: env vars > kwargs > defaults. + + Parameters + ---------- + kwargs + Function-level overrides + """ + for field in fields(self): + # Get environment variable value + key = f"BL_{field.name.upper()}" + val = self._get_env_value(key, field.type) + + # Resolve priority: env > kwargs > default + if val is not None: + res = val + elif field.name in kwargs: + res = kwargs[field.name] + else: + # Get default value from field + res = ( + field.default + if field.default is not field.default_factory + else field.default_factory() + ) + + # Cannot use regular setattr in a frozen dataclass + object.__setattr__(self, field.name, res) + + def _get_env_value(self, env_key: str, field_type: type) -> Any: + """Get and convert environment variable based on field type. + + Parameters + ---------- + env_key : str + Env variable key to get the value from. + field_type : type + Type to convert the env variable to. + + Returns + ------- + Any + The value of the env variable converted to the target type, or None if the env variable is not set. + + Raises + ------ + ValueError if the env variable value could not be converted to the target type. + """ + env_str = os.environ.get(env_key) + if env_str is None: + return None + + if field_type is bool: + return env_str.lower() in ("1", "true", "yes", "on") + elif field_type is int: + return int(env_str) + elif field_type is str: + return env_str + elif issubclass(field_type, IntEnum): + try: + return field_type(int(env_str)) + except ValueError: + return field_type[env_str] + else: + return field_type(env_str) + + def get_env_variables(self) -> dict[str, Any]: + """Return the env variables that are set and will influence these settings. + + Returns + ------- + dict[str, Any] + A dict from variable keys to values (with proper types). + """ + vars = {} + for field in fields(self): + key = f"BL_{field.name.upper()}" + val = self._get_env_value(key, field.type) + + if val is not None: + vars[key] = val + + return vars + + def as_dict(self) -> dict[str, Any]: + """Returns the settings as a dict.""" + # A bit more comfortable than having to import dataclasses.asdict each time + return asdict(self) + + +def _update_settings(**overrides) -> None: + """Replace the _SETTINGS object with a new instance with updated values. Only non-None values are applied. + + This should only be called right after the launch process has started. + + Parameters + ---------- + overrides : + Values to override. See [_Settings][] for valid keywords. + """ + global _SETTINGS + + updates = {} + for field in fields(_SETTINGS): + if field.name not in overrides: + continue + + value = overrides.get(field.name) + if value is None: + continue + + if issubclass(field.type, IntEnum): + if isinstance(value, int): + value = field.type(value) + elif isinstance(value, str): + value = field.type[value] + + updates[field.name] = value + + if updates: + _SETTINGS = replace(_SETTINGS, **updates) + + +def Settings() -> _Settings: + """Get the current settings. + + This function serves two purposes: 1. dissuades replacing the _SETTINGS object, and 2. ensures that importing modules always get the most recent state (instead of the state on import). + + Returns + ------- + Settings + The current settings. + """ + return _SETTINGS + + +_SETTINGS = _Settings() diff --git a/src/lib/better_launch/better_launch/wrapper.py b/src/lib/better_launch/better_launch/wrapper.py new file mode 100644 index 0000000000..544e3c7201 --- /dev/null +++ b/src/lib/better_launch/better_launch/wrapper.py @@ -0,0 +1,416 @@ +from typing import Callable +import os +import builtins +import platform +from ast import literal_eval +import signal +import inspect +import click +import threading +import docstring_parser as doc + +from better_launch.launcher import ( + BetterLaunch, + _bl_singleton_instance, + _bl_include_args, +) +from better_launch.utils.settings import Colormode, Settings, _update_settings +from better_launch.utils.better_logging import init_logging +from better_launch.utils.introspection import find_calling_frame +from better_launch.utils.click import ( + DeclaredArg, + get_click_options, + get_click_bl_options, + get_click_launch_command, +) +from better_launch.ros import logging as roslog + + +_is_launcher_defined = "__better_launch_this_defined" + + +def launch_this( + launch_func: Callable = None, + *, + ui: bool = False, + colormode: Colormode = None, + print_limit: int = None, + screen_log_level: str | int = None, + screen_log_format: str = None, + file_log_level: str | int = None, + file_log_format: str = None, + use_sim_time: bool = None, + manage_foreign_nodes: bool = False, + join: bool = True, + keep_alive: bool = False, +): + """Use this to decorate your launch function. The function will be run automatically. The function is allowed to block even when using the UI. + + **NOTE:** this decorator cannot be used more than once per module. + + Parameters + ---------- + launch_func : Callable, optional + Your launch function, typically using BetterLaunch to start ROS2 nodes. + ui : bool, optional + Whether to start the better_launch TUI. Superseded by the `BL_UI` environment variable and the `--bl_ui_override` argument. + colormode : Colormode, optional + Decides what colors will be used for: + * default: one color per log severity level and a single color for all message sources + * severity: one color per log severity, don't colorize message sources + * source: one color per message source, don't colorize log severity + * none: don't colorize anything + * rainbow: colorize log severity and give each message source its own color + Superseded by the `BL_COLORMODE` environment variable and the `--bl_colormode_override` argument. + print_limit : int, optional + Limit the length of messages printed to the screen. + screen_log_level : str | int, optional + The minimum level for log messages to be printed to the terminal/screen. Can be either "info", "warning", "error", "critical", or an arbitrary integer (e.g. logging.WARNING). + screen_log_format : str, optional + Customize how log output will be formatted when printing it to the screen. Will be overridden by the `BL_SCREEN_LOG_FORMAT` environment variable. See [PrettyLogFormatter][better_launch.utils.better_logging.PrettyLogFormatter] for details. + file_log_level : str | int, optional + The minimum level for log messages to be written to the lot file. Can be either "info", "warning", "error", "critical", or an arbitrary integer (e.g. logging.WARNING). + file_log_format : str, optional + Customize how log output will be formatted when writing it to a file. Will be overridden by the `BL_FILE_LOG_FORMAT` environment variable. See [PrettyLogFormatter][better_launch.utils.better_logging.PrettyLogFormatter] for details. + manage_foreign_nodes : bool, optional + If True, the TUI will also include node processes not started by this process. Has no effect if the TUI is not started. + join : bool, optional + If True, join the better_launch process. Has no effect when ui == True. + keep_alive : bool, optional + If True, keep the process alive even when all nodes have stopped. + """ + + # Settings of included launchfiles will be ignored + # NOTE be careful not to instantiate BetterLaunch before the launch function has run + if not BetterLaunch.is_included(): + _update_settings( + ui=ui, + colormode=colormode, + print_limit=print_limit, + screen_log_level=screen_log_level, + screen_log_format=screen_log_format, + file_log_level=file_log_level, + file_log_format=file_log_format, + use_sim_time=use_sim_time, + ) + + def decoration_helper(func): + sig = inspect.signature(func) + declared_args = _get_declared_args(sig, func.__doc__) + + func_doc = doc.parse(func.__doc__) + + argspec = inspect.getfullargspec(func) + allow_kwargs = argspec[2] is not None + + return _exec_launch_func( + func, + declared_args, + func_doc, + join=join, + manage_foreign_nodes=manage_foreign_nodes, + keep_alive=keep_alive, + allow_kwargs=allow_kwargs, + ) + + return decoration_helper if launch_func is None else decoration_helper(launch_func) + + +def _init_signal_handlers() -> None: + # Signal handlers have to be installed on the main thread. Since the BetterLaunch singleton + # could be instantiated first on a different thread we do it here where we can make stronger + # requirements. + if threading.current_thread() != threading.main_thread(): + raise RuntimeError("launch_this must be used on the main thread") + + sigint_count = 0 + + def sigint_handler(sig, frame): + nonlocal sigint_count + sigint_count += 1 + + # Some terminals will send SIGINT multiple times on ctrl-c, so we ignore the second one + if sigint_count == 2: + return + + BetterLaunch()._on_sigint(sig, frame) + + def sigterm_handler(sig, frame): + BetterLaunch()._on_sigterm(sig, frame) + + signal.signal(signal.SIGINT, sigint_handler) + signal.signal(signal.SIGTERM, sigterm_handler) + + if platform.system() != "Windows": + signal.signal(signal.SIGQUIT, sigterm_handler) + + +def _get_declared_args( + signature: inspect.Signature, docstring: str = None +) -> list[DeclaredArg]: + # Extract more fine-grained information from the docstring + param_docstrings = {} + if docstring: + parsed_doc = doc.parse(docstring) + param_docstrings = {p.arg_name: p.description for p in parsed_doc.params} + + declared_args = [] + + # Create CLI options for click + for param in signature.parameters.values(): + ptype = None + default = DeclaredArg._undefined + + if param.annotation is not param.empty: + ptype = param.annotation + if ptype and isinstance(ptype, str): + # If it's a primitive type we can parse it, otherwise ignore it + # NOTE use the proper builtins module here, __builtins__ is unreliable + ptype = getattr(builtins, ptype, None) + + if param.default is not inspect.Parameter.empty: + default = param.default + + if ptype is None and default is not None: + ptype = type(default) + + declared_args.append( + DeclaredArg(param.name, ptype, default, param_docstrings.get(param.name)) + ) + + return declared_args + + +def _exec_launch_func( + launch_func: Callable, + declared_args: list[DeclaredArg], + func_doc: str = None, + *, + launchfile: str = None, + manage_foreign_nodes: bool = False, + join: bool = True, + keep_alive: bool = False, + allow_kwargs: bool = False, + # NOTE for internal use only, we don't want launchfiles that ignore their CLI args + _argv: list[str] = None, +): + # NOTE this function should not make any assumptions about the launch_func + + # Globals of the calling module + launch_frame = find_calling_frame(_exec_launch_func) + glob = launch_frame.frame.f_globals + + if glob.get(_is_launcher_defined, False) and _bl_singleton_instance not in glob: + # Allow using launch_this only once unless we got included from another file + raise RuntimeError("Can only use one launch decorator") + + glob[_is_launcher_defined] = True + + # NOTE be careful not to instantiate BetterLaunch before the launch function has run + if BetterLaunch.is_included(): + # We have been included from another file, run the launch function and skip the remaining + # initialization as its already been taken care of + bl: BetterLaunch = glob[_bl_singleton_instance] + + includefile = launch_frame.filename + include_args: dict = glob[_bl_include_args] + bl.logger.info(f"Including launch file: {includefile} (args={include_args})") + + call_kw = { + a.name: a.default + for a in declared_args + if a.default != DeclaredArg._undefined + } + + for key, val in include_args.items(): + if allow_kwargs or key in call_kw: + call_kw[key] = val + + launch_func(**call_kw) + + return + + # Get the filename of the original launchfile + # At this point we know that we are the main launch file + if launchfile: + BetterLaunch._launchfile = launchfile + else: + BetterLaunch._launchfile = launch_frame.filename + + _init_signal_handlers() + + # If we were started by ros launch (e.g. through 'ros2 launch ') we need + # to expose a "generate_launch_description" method instead of running by ourselves. + # + # Launch files in ROS2 are run by adding an IncludeLaunchDescription action to the + # LaunchService (both found in https://github.com/ros2/launch/). When the action is resolved, + # it ultimately leads to get_launch_description_from_python_launch_file, which imports the file + # and then checks for a generate_launch_description function. + # + # See the following links for details: + # + # https://github.com/ros2/launch_ros/blob/rolling/ros2launch/ros2launch/command/launch.py#L125 + # https://github.com/ros2/launch_ros/blob/rolling/ros2launch/ros2launch/api/api.py#L141 + # https://github.com/ros2/launch/blob/rolling/launch/launch/actions/include_launch_description.py#L148 + # https://github.com/ros2/launch/blob/rolling/launch/launch/launch_description_sources/python_launch_file_utilities.py#L43 + stack = inspect.stack() + for frame_info in stack: + frame_locals = frame_info.frame.f_locals + if "self" not in frame_locals: + continue + + owner = frame_locals["self"] + + if type(owner).__name__ == "IncludeLaunchDescription": + # We were included or started by ROS2, expose the expected launch method in our + # caller's globals and return + print( + f"[NOTE] Launch file {os.path.basename(BetterLaunch._launchfile)} got included from ROS2" + ) + + # TODO Maybe we shouldn't? + init_logging(roslog.launch_config) + + _expose_ros2_launch_function(launch_func, declared_args) + return + + # If we get here we were not included by ROS2 + + @click.pass_context + def run(ctx: click.Context, *args, **kwargs): + init_logging(roslog.launch_config) + + if allow_kwargs: + # If the launch func defines a **kwarg we can pass all extra arguments to it, with + # the caveat that these extra args need to be defined as `-[-] val` tuples. + assert len(ctx.args) % 2 == 0, ( + f"extra arguments need to be '-- ' tuples ({ctx.args})" + ) + + for i in range(0, len(ctx.args), 2): + key = ctx.args[i] + if not key.startswith("-"): + raise ValueError("Extra argument keys must start with a dash") + + val = ctx.args[i + 1] + try: + val = literal_eval(val) + except Exception: + # Keep val as a string + pass + + kwargs[key.strip("-")] = val + + # By default BetterLaunch has access to all arguments from its launch function + BetterLaunch._launch_func_args = dict(kwargs) + + # Wrap the launch function so we can do some preparation and cleanup tasks. + def launch_func_wrapper(): + try: + # Execute the launch function! + launch_func(*args, **kwargs) + except Exception as e: + bl = BetterLaunch.instance() + if bl and not bl.is_shutdown: + bl.shutdown(f"Exception in launch file: {e}") + + raise + + # Retrieve the BetterLaunch singleton + bl = BetterLaunch() + + # The UI will manage spinning itself + if join and not Settings().ui: + bl.spin(exit_with_last_node=not keep_alive) + + if Settings().ui: + from better_launch.tui.better_tui import BetterTui + + app = BetterTui( + launch_func_wrapper, + manage_foreign_nodes=manage_foreign_nodes, + keep_alive=keep_alive, + ) + app.run() + else: + launch_func_wrapper() + + options = get_click_options(declared_args) + options.extend(get_click_bl_options()) + + click_cmd = get_click_launch_command( + BetterLaunch._launchfile, + run, + options, + func_doc, + allow_kwargs=allow_kwargs, + ) + + if allow_kwargs: + click_cmd.allow_extra_args = True + click_cmd.ignore_unknown_options = True + + try: + click_cmd.main(_argv) + except SystemExit as e: + if e.code != 0: + raise + + +def _expose_ros2_launch_function( + launch_func: Callable, declared_args: list[DeclaredArg] +): + """Helper function that exposes a function decorated by launch_this so that it can be included by a regular ROS2 launch file. We achieve this by generating a `generate_launch_description` function and adding it to the module globals where the launch function is defined. + + Parameters + ---------- + launch_func : Callable + The launch function. + declared_args : list[LaunchArg] + Arguments that should be declared. + """ + + def generate_launch_description(): + import asyncio + from launch import LaunchDescription, LaunchContext + from launch.actions import DeclareLaunchArgument, OpaqueCoroutine + + ld = LaunchDescription() + + # Declare launch arguments from the function signature + for arg in declared_args: + default = None + if arg.default not in (None, DeclaredArg._undefined): + default = str(arg.default) + + ld.add_action(DeclareLaunchArgument(arg.name, default_value=default)) + + async def ros2_wrapper(context: LaunchContext): + launch_args = {} + for k, v in context.launch_configurations.items(): + try: + launch_args[k] = literal_eval(v) + except (ValueError, SyntaxError): + # Probably a string + # issue #11: SyntaxError happens when a path is passed without quotes + # NOTE this should also make passing args to ROS2 much easier + launch_args[k] = v + + # Call the launch function + launch_func(**launch_args) + + # We must stay alive until the last node has exited + bl = BetterLaunch.instance() + if bl: + while any([n for n in bl.get_nodes() if n.is_running]): + await asyncio.sleep(0.1) + + # A bit of an obscure one, but this way we can stay alive even when all other launch + # actions have terminated + ld.add_action(OpaqueCoroutine(coroutine=ros2_wrapper)) + return ld + + # Add our generate_launch_description function to the module launch_this was called from + launch_frame = find_calling_frame(_exec_launch_func) + caller_globals = launch_frame.frame.f_globals + caller_globals["generate_launch_description"] = generate_launch_description diff --git a/src/lib/better_launch/bin/bl b/src/lib/better_launch/bin/bl new file mode 100755 index 0000000000..2b07786d38 --- /dev/null +++ b/src/lib/better_launch/bin/bl @@ -0,0 +1,545 @@ +#!/usr/bin/env python3 + +""" +This script should be used as follows: + +bl + +- Autocomplete after bl will list available ROS2 packages. +- Automcplate after will list launch files (.py, .xml, .yaml, .yml) and executable files in the package. +- A dash ("-") followed by autocomplete after should list launch args for better_launch launch files and declared args for ROS2 launch files. + +See --help and readme for further details. +""" + +from typing import Any +import sys +import os +import ast +import yaml +from xml.etree import ElementTree +import click +from click.shell_completion import CompletionItem +from docstring_parser import parse as parse_docstring + +from ament_index_python.packages import ( + get_packages_with_prefixes, + get_package_prefix, + get_package_share_directory, +) + +try: + # Jazzy + from rclpy.parameter import get_parameter_value, parameter_value_to_python +except ImportError: + # Humble + from ros2param.api import ( + get_parameter_value as get_parameter_value_humble, + get_value as get_value_humble, + ) + + get_parameter_value = lambda s: get_parameter_value_humble(string_value=s) + parameter_value_to_python = lambda p: get_value_humble(parameter_value=p) + + +from better_launch import __version__ +from better_launch.utils.introspection import get_launchfunc_signature_from_file +from better_launch.utils.click import ( + get_click_options, + get_click_bl_options, +) + + +class LaunchFile: + @classmethod + def from_file(cls, filepath: str) -> "LaunchFile": + """Returns a LaunchFile subclass that can handle the provided launch file. + + Parameters + ---------- + filepath : str + The path to the launch file. + + Returns + ------- + LaunchFile + An instance that can handle the provided launch file. + + Raises + ------ + ValueError + If no subclass could be found that can handle the provided launch file. + """ + # Handles only immediate subclasses. + # NOTE: by using __subclasses__ the order subclasses are defined in actually matters + for launch_cls in LaunchFile.__subclasses__(): + try: + return launch_cls(filepath) + except Exception: + # May throw a variety of errors like FileNotFound (dead symlinks), parsing + # errors, permission errors... we just don't want to interrupt the autocomplete + pass + + raise ValueError(f"{filepath} is not a valid launch file") + + def __init__( + self, + filepath: str, + params: list[click.Option], + doc: str, + ): + """Create a representation describing the most important information about a launch file. + + Parameters + ---------- + launchfile : str + The filename of the launch file. + filepath : str + The full path to the launch file. + params : list[click.Option] + ROS2 parameters that have been identified for the launch file, represented as click options. + doc : str + A docstring describing the launch file, if any. + """ + self.filepath = filepath + self.name = os.path.basename(filepath) + self.params = params + self.doc = doc or filepath + + def run(self, ctx: click.Context, **kwargs) -> None: + """Execute this launch file.""" + raise NotImplementedError() + + +class BetterLaunchPython(LaunchFile): + """This LaunchFile will handle better_launch python launch files.""" + + @classmethod + def _get_launch_params_and_doc_bl_python( + cls, filepath: str + ) -> tuple[list[click.Option], str]: + from better_launch.wrapper import _get_declared_args + + _, signature, func_doc = get_launchfunc_signature_from_file(filepath) + if not signature: + raise ValueError("Not a better_launch launch file") + + launch_args = _get_declared_args(signature, func_doc) + + options = get_click_options(launch_args) + options.extend(get_click_bl_options(expose=True)) + + doc = "" + if func_doc: + parsed_doc = parse_docstring(func_doc) + doc = parsed_doc.short_description + if parsed_doc.long_description: + doc += "\n\n" + parsed_doc.long_description + + return options, doc + + def __init__(self, filepath: str): + params, doc = BetterLaunchPython._get_launch_params_and_doc_bl_python(filepath) + super().__init__(filepath, params, doc) + + def run(self, ctx: click.Context, **kwargs) -> None: + print( + f"Delegating to better_launch: {self.name}, args={kwargs}", + flush=True, + ) + + # First argument becomes argv[0], so should be the program name + args = ["python3", self.filepath] + for key, arg in kwargs.items(): + if arg is not None: + args.extend([f"--{key}", arg]) + + # This does NOT return and will replace our executable with the new executable + # in the same process + os.execvp("python3", [str(a) for a in args]) + + +class BetterLaunchToml(LaunchFile): + """This LaunchFile will handle better_launch TOML launch files.""" + + @classmethod + def _get_launch_params_and_doc_bl_toml( + cls, filepath: str + ) -> tuple[list[click.Option], str]: + from better_launch.declarative import _get_toml_args + from better_launch.toml import load as load_toml + + toml = load_toml(filepath) + doc = toml.get("__comment__") + declared_args = _get_toml_args(toml) + + options = get_click_options(declared_args) + # We'll stay in the same process, so there's no need to expose these on the CLI + options.extend(get_click_bl_options(expose=False)) + + return options, doc + + def __init__(self, filepath: str): + params, doc = BetterLaunchToml._get_launch_params_and_doc_bl_toml(filepath) + super().__init__(filepath, params, doc) + + def run(self, ctx: click.Context, **kwargs) -> None: + from better_launch.declarative import launch_toml + + print( + f"Calling launch_toml: {self.name}, args={kwargs}", + flush=True, + ) + + # Since the toml files are not executable, we need something else to run them. We + # could create another script with a main function, but I don't think there's much + # of a point to this. + launch_toml(self.filepath, launch_args=kwargs) + + +class Ros2LaunchFile(LaunchFile): + """A LaunchFile that can handle regular ROS2 launch files (python, XML, and YAML).""" + + @classmethod + def _option_from_ros2_kwargs(cls, kwargs: dict[str, Any]) -> click.Option: + def eval_arg(arg: Any) -> Any: + try: + return ast.literal_eval(arg) + except Exception: + return None + + name = eval_arg(kwargs.get("name", None)) + description = eval_arg(kwargs.get("description", None)) + default = eval_arg(kwargs.get("default_value", None)) + choices = eval_arg(kwargs.get("choices", None)) + + if default: + # Yes ROS2, I also think that simple things are boring and uninspiring... + default = parameter_value_to_python(get_parameter_value(str(default))) + + if choices: + ptype = click.Choice(choices) + else: + ptype = type(default) if default is not None else None + + return click.Option( + [f"--{name}"], + type=ptype, + help=description, + default=default, + ) + + @classmethod + def _get_launch_params_and_doc_ros2_python( + cls, filepath: str + ) -> tuple[list[click.Option], str]: + try: + with open(filepath, "r", encoding="utf-8") as f: + source = f.read() + + tree = ast.parse(source) + except Exception: + raise ValueError("Not valid python code") + + has_launch_func = False + doc = None + options = [] + + for node in ast.walk(tree): + if ( + isinstance(node, ast.FunctionDef) + and node.name == "generate_launch_description" + ): + doc = ast.get_docstring(node) + has_launch_func = True + + elif isinstance(node, ast.Call): + call_name = getattr(node.func, "id", getattr(node.func, "attr", None)) + if call_name == "DeclareLaunchArgument": + kwargs = {key.arg: key.value for key in node.keywords if key} + + if node.args: + # Passed as a positional argument + kwargs["name"] = node.args[0] + + # XML and YAML are very consistent here, but DeclareLaunchArgument is... not + kwargs["default"] = kwargs.get("default_value", None) + + option = cls._option_from_ros2_kwargs(kwargs) + options.append(option) + + if not has_launch_func: + raise ValueError("Not a ROS2 launch file") + + return options, doc + + @classmethod + def _get_launch_params_and_doc_ros2_xml( + cls, filepath: str + ) -> tuple[list[click.Option], str]: + tree = ElementTree.parse(filepath) + root = tree.getroot() + + if root.tag != "launch": + raise ValueError("Not an xml launch file") + + args = [arg.attrib for arg in root.findall("arg")] + + options = [] + doc = "" # Not clear how to get a comment above the root element + + for kwargs in args: + option = cls._option_from_ros2_kwargs(kwargs) + options.append(option) + + return options, doc + + @classmethod + def _get_launch_params_and_doc_ros2_yaml( + cls, filepath: str + ) -> tuple[list[click.Option], str]: + if not filepath.lower().endswith((".launch.yml", ".launch.yaml")): + raise ValueError("Not a yaml file") + + with open(filepath) as f: + content = yaml.safe_load(f) + + if "launch" not in content: + raise ValueError("Not a yaml launch file") + + definitions: list[dict] = content["launch"] + options = [] + doc = "" # PyYAML does not preserve comments + + for item in definitions: + if "arg" in item: + # YAML might only support name and default, but who knows! + option = cls._option_from_ros2_kwargs(item["arg"]) + options.append(option) + + return options, doc + + def __init__(self, filepath: str): + for func in [ + Ros2LaunchFile._get_launch_params_and_doc_ros2_python, + Ros2LaunchFile._get_launch_params_and_doc_ros2_xml, + Ros2LaunchFile._get_launch_params_and_doc_ros2_yaml, + ]: + try: + params, doc = func(filepath) + break + except ValueError: + pass + else: + raise ValueError("Not a ROS2 launch file") + + super().__init__(filepath, params, doc) + + def run(self, ctx: click.Context, **kwargs) -> None: + print( + f"Delegating to ROS2 launch: {self.name}, args={kwargs}", + flush=True, + ) + + # First argument becomes argv[0], so should be the program name + args = ["ros2", "launch", self.filepath] + args.extend([f"{key}:={arg}" for key, arg in kwargs.items()]) + + # This does NOT return and will replace our executable with the new executable in the same process + os.execvp("ros2", [str(a) for a in args]) + + +class ExecutableLaunchFile(LaunchFile): + """A LaunchFile that handles executable files.""" + + def __init__(self, filepath: str): + if not os.access(filepath, os.X_OK): + raise ValueError("Not executable") + + params = [] + doc = None + super().__init__(filepath, params, doc) + + def run(self, ctx: click.Context, **kwargs) -> None: + print( + f"Directly executing launch file: {self.name}, args={kwargs}", + flush=True, + ) + + # First argument becomes argv[0], so should be the program name + args = sys.argv[2:] + + # This does NOT return and will replace our executable with the new executable in the same process + os.execvp(self.filepath, [str(a) for a in args]) + + +def get_launchfile_command(launchfile: LaunchFile, ctx: click.Context) -> click.Command: + # return a launchfile command with launch args as its Options + ctx.obj = { + "launchfile": launchfile, + } + + cmd = click.Command( + launchfile.name, + callback=click.pass_context(launchfile.run), + params=launchfile.params, + help=launchfile.doc, + ) + + # In case the launch file accepts additional arguments. Unfortunately, in ROS2 this not only depends on the launch file, but also on the ROS2 node itself, specifically the allow_undeclared_parameters argument. + cmd.ignore_unknown_options = True + cmd.allow_extra_args = True + + return cmd + + +class LaunchfileSelectionCLI(click.Group): + def __init__(self, package: str, **kwargs): + """This subcommand will handle selecting a launch file from the provided ROS package. + + Parameters + ---------- + package : str + The ROS package to search for launch files. + """ + super().__init__(**kwargs) + self._package = package + self._package_path = get_package_prefix(package) + + def list_commands(self, ctx: click.Context) -> list[str]: + executables = self._get_executables() + executables.sort() + return list(map(os.path.basename, executables)) + + def get_command(self, ctx: click.Context, name: str) -> click.Command: + executables = self._get_executables() + candidates = [ex for ex in executables if os.path.basename(ex) == name] + + if not candidates: + print(f"Launchfile {name} not found in package {self._package}") + return None + + if len(candidates) > 1: + print( + f"Multiple executable files named {name} found in package {self._package}" + ) + return None + + try: + launchfile = LaunchFile.from_file(candidates[0]) + except ValueError: + return None + + return get_launchfile_command(launchfile, ctx) + + def _get_executables(self, include_launchfiles: bool = True) -> list[str]: + # list executable files inside our package + if not self._package_path: + print(f"Packge {self._package} could not be found") + return [] + + package_paths = [ + os.path.join(self._package_path, "lib", self._package), + get_package_share_directory(self._package), + ] + executables = [] + + for base_path in package_paths: + for dirpath, dirnames, filenames in os.walk(base_path): + dirnames[:] = [d for d in dirnames if not d.startswith(".")] + dirnames.sort() + + # Look for executable files + for filename in sorted(filenames): + path = os.path.join(dirpath, filename) + if os.access(path, os.X_OK): + executables.append(path) + elif include_launchfiles and ( + filename.endswith( + ( + ".launch.py", + ".launch.xml", + ".launch.yaml", + ".launch.yml", + ".launch.toml", + ) + ) + ): + executables.append(path) + + return executables + + +class PackageSelectionCLI(click.Group): + def __init__(self, **kwargs): + """This subcommand will help in selecting a ROS package.""" + options = [ + click.Option( + ["-v", "--version"], + is_flag=True, + callback=self.print_version, + expose_value=False, + is_eager=True, + ) + ] + + super().__init__( + params=options, + subcommand_metavar=" [args]", + help="""\ +The better_launch launcher. + +This script allows you to run both better_launch and ROS2 launch files. While using ROS2 launch should work, it would mean having an additional launch system instance running (worst case: ros2launch -> better_launch -> ros2_launch). + +In contrast to ROS2 launch, it also generates useful shell completion suggestions for packages, launch files and arguments. Launch args declared in your launch files are also recognized and will be completed (you need to type at least one dash though). +""", + epilog="Bugs, ideas, feedback, questions? Find me at https://github.com/ndahn", + **kwargs, + ) + + def print_version( + self, ctx: click.Context, param: click.Parameter, value: Any + ) -> None: + if not value or ctx.resilient_parsing: + return + + click.echo(__version__) + ctx.exit() + + def shell_complete(self, ctx: click.Context, incomplete: str): + if incomplete.startswith(".") or os.path.isabs(incomplete): + return [CompletionItem(incomplete, type="file")] + + return super().shell_complete(ctx, incomplete) + + def list_commands(self, ctx: click.Context) -> list[str]: + # list ROS2 packages + return sorted(get_packages_with_prefixes().keys()) + + def get_command(self, ctx: click.Context, name: str) -> click.Command: + if name.startswith(".") or os.path.isabs(name): + if not os.path.isfile(name): + raise ValueError(f"{name} is not a file") + + # The user has directly provided a file, assume it's a launchfile and run it directly + launchfile = LaunchFile.from_file(name) + return get_launchfile_command(launchfile, ctx) + + try: + # Return a command that will handle selecting launch files from the package + return LaunchfileSelectionCLI(name) + except Exception: + # The LaunchfileSelectionCLI will throw a PackageNotFoundError if colcon failed + # to install a package, and ValueError if the package name is invalid + return None + + def format_commands( + self, ctx: click.Context, formatter: click.HelpFormatter + ) -> None: + # Prevent listing ALL packages when calling without args + pass + + +if __name__ == "__main__": + # Start by selecting a package. Currently we don't provide a solution for directly executing a launch file by its path. This may be supported in the future though. + PackageSelectionCLI().main() diff --git a/src/lib/better_launch/bin/launch_graph.py b/src/lib/better_launch/bin/launch_graph.py new file mode 100644 index 0000000000..746077c267 --- /dev/null +++ b/src/lib/better_launch/bin/launch_graph.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +""" +Proof of concept, works but doesn't seem overly useful yet. +Use like so: + +python bin/launch_graph.py examples/03_composition.launch.py > graph.dot +dot -Tpng graph.dot -o graph.png + +Todo: +- strip comments after function unparsed() arguments +- give special kind to block comments +- make the CFG easier to work with +- find a better representation for export than dot +- write additional handlers for ROS2 launch files (python, xml, yaml) +""" + +from typing import Literal +import ast +import sys + + +CfgNodeKind = Literal[ + "stmt", + "bl", + "cond", + "start", + "end", + "nop", +] + + +class ControlFlowGraph: + def __init__(self): + self.nodes: list[int, str, str] = [] # (id, label, kind) + self.edges: list[int, int, str] = [] # (src, dst, label) + self.next_id = 0 + + def new_node(self, label: str, kind: CfgNodeKind = "stmt") -> int: + nid = self.next_id + self.next_id += 1 + self.nodes.append((nid, label, kind)) + return nid + + def add_edge(self, src: int, dst: int, label: str = None): + self.edges.append((src, dst, label)) + + def merge_exits(self, *exit_sets: list[set[int]]) -> set[int]: + out = set() + for s in exit_sets: + out |= set(s) + return out + + def to_dot(self) -> str: + lines = [ + "digraph G {", + ' node [shape=box,fontname="Helvetica"];', + " rankdir=TB;", + ] + + for nid, label, kind in self.nodes: + shape = { + "stmt": "box", + "bl": "ellipse", + "cond": "diamond", + "start": "oval", + "end": "oval", + "nop": "point", + }.get(kind, "box") + safe_label = (label or "").replace('"', r"\"") + lines.append(f' n{nid} [shape={shape},label="{safe_label}"];') + + for src, dst, elabel in self.edges: + lab = f' [label="{elabel}"]' if elabel else "" + lines.append(f" n{src} -> n{dst}{lab};") + + lines.append("}") + return "\n".join(lines) + + +class BetterLaunchCallTracker: + def __init__(self): + self.class_names = {"BetterLaunch"} + self.instance_names: set[str] = set() + + def feed_import(self, node: ast.AST): + if isinstance(node, ast.ImportFrom) and node.module == "better_launch": + for alias in node.names: + if alias.name == "BetterLaunch": + self.class_names.add(alias.asname or alias.name) + + def track_assignment(self, target: ast.AST, value: ast.AST): + if isinstance(value, ast.Call): + f = value.func + if isinstance(f, ast.Name) and f.id in self.class_names: + if isinstance(target, ast.Name): + self.instance_names.add(target.id) + + def track_context_target(self, target: ast.AST, ctx_expr: ast.AST): + # If the with-context is a BL call and we have "as ", track that name too + if isinstance(ctx_expr, ast.Call) and self.is_bl_call(ctx_expr): + if isinstance(target, ast.Name): + self.instance_names.add(target.id) + + def is_bl_call(self, call: ast.Call) -> str: + f = call.func + if isinstance(f, ast.Attribute) and isinstance(f.value, ast.Name): + if f.value.id in self.instance_names: + return f.attr + return None + + +class CFGBuilder(ast.NodeVisitor): + def __init__(self, cfg: ControlFlowGraph, bl: BetterLaunchCallTracker): + self.cfg = cfg + self.bl = bl + self.source = "" + + def build_module(self, module: ast.Module): + for n in module.body: + if isinstance(n, (ast.ImportFrom, ast.Import)): + self.bl.feed_import(n) + + start = self.cfg.new_node("START", "start") + entry, exits = self.build_block(module.body) + if entry is not None: + self.cfg.add_edge(start, entry) + + end = self.cfg.new_node("END", "end") + for e in exits: + self.cfg.add_edge(e, end) + + def build_block(self, stmts: list[ast.stmt]) -> tuple[int, set[int]]: + prev_exits: set[int] = None + entry: int = None + + for s in stmts: + e_entry, e_exits = self.build_stmt(s) + + if e_entry is None: + continue + + if entry is None: + entry = e_entry + + if prev_exits is not None: + for p in prev_exits: + self.cfg.add_edge(p, e_entry) + + prev_exits = e_exits + + if entry is None: + nop = self.cfg.new_node("", "nop") + return nop, {nop} + + return entry, prev_exits or set() + + def build_stmt(self, node: ast.stmt) -> tuple[int, set[int]]: + if isinstance(node, ast.FunctionDef): + label = f"def {node.name}(" + ", ".join(a.arg for a in node.args.args) + ")" + fentry = self.cfg.new_node(label, "start") + + b_entry, b_exits = self.build_block(node.body) + if b_entry is not None: + self.cfg.add_edge(fentry, b_entry) + + fend = self.cfg.new_node(f"end {node.name}", "end") + for e in b_exits: + self.cfg.add_edge(e, fend) + + return fentry, {fend} + + if isinstance(node, ast.If): + cond_src = ast.get_source_segment(self.source, node.test) + if not cond_src: + cond_src = ast.dump(node.test) + + cond = self.cfg.new_node(f"if {cond_src}", "cond") + + then_entry, then_exits = self.build_block(node.body) + else_entry, else_exits = ( + self.build_block(node.orelse) if node.orelse else (None, set()) + ) + if then_entry is None: + then_entry, then_exits = self.build_block([]) + if else_entry is None: + else_entry, else_exits = self.build_block([]) + + self.cfg.add_edge(cond, then_entry, "True") + self.cfg.add_edge(cond, else_entry, "False") + + return cond, self.cfg.merge_exits(then_exits, else_exits) + + if isinstance(node, ast.While): + test_src = ast.get_source_segment(self.source, node.test) + if not test_src: + test_src = ast.dump(node.test) + + cond = self.cfg.new_node(f"while {test_src}", "cond") + + body_entry, body_exits = self.build_block(node.body) + if body_entry is None: + body_entry, body_exits = self.build_block([]) + self.cfg.add_edge(cond, body_entry, "True") + + for e in body_exits: + self.cfg.add_edge(e, cond, "loop") + end = self.cfg.new_node("while_end", "nop") + + if node.orelse: + else_entry, else_exits = self.build_block(node.orelse) + if else_entry is not None: + self.cfg.add_edge(cond, else_entry, "False") + for e in else_exits: + self.cfg.add_edge(e, end) + else: + self.cfg.add_edge(cond, end, "False") + else: + self.cfg.add_edge(cond, end, "False") + + return cond, {end} + + if isinstance(node, ast.For): + tgt_src = ast.get_source_segment(self.source, node.target) + if not tgt_src: + tgt_src = ast.dump(node.target) + + iter_src = ast.get_source_segment(self.source, node.iter) + if not iter_src: + iter_src = ast.dump(node.iter) + + cond = self.cfg.new_node(f"for {tgt_src} in {iter_src}", "cond") + + body_entry, body_exits = self.build_block(node.body) + if body_entry is None: + body_entry, body_exits = self.build_block([]) + self.cfg.add_edge(cond, body_entry, "Iter") + + for e in body_exits: + self.cfg.add_edge(e, cond, "next") + end = self.cfg.new_node("for_end", "nop") + + if node.orelse: + else_entry, else_exits = self.build_block(node.orelse) + if else_entry is not None: + self.cfg.add_edge(cond, else_entry, "Empty") + for e in else_exits: + self.cfg.add_edge(e, end) + else: + self.cfg.add_edge(cond, end, "Empty") + else: + self.cfg.add_edge(cond, end, "Empty") + + return cond, {end} + + if isinstance(node, ast.Return): + src = ast.get_source_segment(self.source, node) or "return" + n = self.cfg.new_node(src, "stmt") + return n, set() + + if isinstance(node, (ast.Assign, ast.AugAssign, ast.AnnAssign, ast.Expr)): + label_src = ( + ast.get_source_segment(self.source, node) or node.__class__.__name__ + ) + kind = "stmt" + + if isinstance(node, ast.Assign): + value = node.value + if isinstance(value, ast.Call): + self.bl.track_assignment(node.targets[0], value) + meth = self.bl.is_bl_call(value) + if meth: + args = ", ".join(self.get_call_args(value)) + kind, label_src = "bl", f"BL.{meth}({args})" + + elif isinstance(node, ast.Expr) and isinstance(node.value, ast.Call): + meth = self.bl.is_bl_call(node.value) + if meth: + args = ", ".join(self.get_call_args(node.value)) + kind, label_src = "bl", f"BL.{meth}({args})" + + n = self.cfg.new_node(label_src, kind) + return n, {n} + + if isinstance(node, (ast.With, ast.AsyncWith)): + # Build a single "with ..." entry node that can include multiple items + labels = [] + is_bl = False + + for item in node.items: + ctx = item.context_expr + ctx_src = ast.get_source_segment(self.source, ctx) or ast.dump(ctx) + + if isinstance(ctx, ast.Call): + meth = self.bl.is_bl_call(ctx) + if meth: + args = ", ".join(self.get_call_args(ctx)) + labels.append(f"BL.{meth}({args})") + is_bl = True + # track "as " target as a BL-like handle + if item.optional_vars: + self.bl.track_context_target(item.optional_vars, ctx) + else: + labels.append(f"with {ctx_src}") + else: + labels.append(f"with {ctx_src}") + + label = " | ".join(labels) if labels else "with" + kind = "bl" if is_bl else "stmt" + + with_entry = self.cfg.new_node(label, kind) + body_entry, body_exits = self.build_block(node.body) + if body_entry is None: + body_entry, body_exits = self.build_block([]) + self.cfg.add_edge(with_entry, body_entry) + + with_end = self.cfg.new_node("with_end", "nop") + for e in body_exits: + self.cfg.add_edge(e, with_end) + + return with_entry, {with_end} + + src = ast.get_source_segment(self.source, node) or node.__class__.__name__ + n = self.cfg.new_node(src, "stmt") + return n, {n} + + def get_call_args(self, call: ast.Call) -> list[str]: + args = [ast.unparse(a) for a in call.args] + args.extend(f"{k.arg}={ast.unparse(k.value)}" for k in call.keywords if k.arg) + return args + + +def build_cfg_from_path(path: str, strip_comments: bool = True) -> ControlFlowGraph: + with open(path, "r", encoding="utf-8") as f: + source = f.read() + + if strip_comments: + # TODO + pass + + module = ast.parse(source, filename=path, type_comments=True) + + cfg = ControlFlowGraph() + bl = BetterLaunchCallTracker() + builder = CFGBuilder(cfg, bl) + builder.source = source + builder.build_module(module) + + return cfg + + +def main(): + if len(sys.argv) < 2: + print(f"Usage: python {sys.argv[0]} > graph.dot") + return + + cfg = build_cfg_from_path(sys.argv[1]) + print(cfg.to_dot()) + + +if __name__ == "__main__": + main() diff --git a/src/lib/better_launch/docs/about/differences.md b/src/lib/better_launch/docs/about/differences.md new file mode 100644 index 0000000000..f38f5bd5e8 --- /dev/null +++ b/src/lib/better_launch/docs/about/differences.md @@ -0,0 +1,48 @@ +# :balance_scale: What are the differences? +Because *better_launch* does not use the ROS2 launch system, some aspects work differently from what you may be used to. + + +## Action immediacy +In ROS2 launch, launchfiles create tasks that are then passed to an asynchronous event loop. This is the reason why e.g. checking for launch parameter values is so incredibly weird - they simply don't exist yet by the time you define the actions. In *better_launch* however, all actions are taken immediately: if you create a node, its process is started right away; if you include another launchfile, its contents will be handled before the function returns. + +The only exception to this is adding ROS2 launch actions, e.g. by including regular ROS2 launchfiles. Since these still rely on the ROS2 launch system, they need to be turned into asynchronous tasks and passed to the event loop. Usually a ROS2 `LaunchService` sub-process is started the first time a ROS2 action is passed to *better_launch*. From then on this process will handle all ROS2 actions asynchronously in the background. + +???+ info + + While the output of the ROS2 launch service process (and its nodes) is captured and formatted by *better_launch* just like for all other nodes, these will usually appear and behave as one single `launch_service` unit in the TUI. + + +## Lifecycle nodes +Lifecycle nodes differ from regular nodes in that they don't become fully active after their process starts. Instead you have to call one of their lifecycle management services, usually via additional code in your launchfile or the `ros2 lifecycle` CLI. However, in the end they are still just nodes. + +*better_launch* makes no distinction between regular and lifecycle nodes. Instead, all "lifecyclable" objects (e.g. nodes and components) provide a `LifecycleManager` object via their `lifecycle` member. This will be `None` if the object has not been identified (yet) as a lifecycle-thing - otherwise you can use it to manage the object's lifecycle. + +???+ note + + All objects that turn out to be lifecyclable will transition to their *ACTIVE* state by default, unless you pass a different target state on instantiation. + + +## Type checking +When passing arguments to a node in ROS2, it is ultimately passed as a stringified command line argument. So why bother with overly strict type checking? Why do I have to turn half the parameters into strings myself? *better_launch* does not impose a flawed type sytem on you and will happily accept `int`, `string`, `float`, etc. where appropriate. In addition, sensible and *unsurprising* types have been chosen for all arguments you may provide (e.g. remaps are defined as a `dict[str, str]`, floats are happy to accept ints, launch arguments are never required to be strings, etc.). + + +## Declaring launch arguments +Simply put: you don't. *better_launch* will check the signature of your launch function and turn all arguments into launch arguments. For example, if your launch function has an `enable_x` argument, you will be able to pass it with `--enable_x ` from the command line. Under the hood *better_launch* is using [click](https://click.palletsprojects.com/), so every launchfile you write comes with proper CLI support. + +???+ tip + + Tip: try adding a docstring to your launch function and call your launchfile with `--help`! + + +## Parameter files +You do **not** have to put `ros__parameters` in your configs anymore when using `BetterLaunch.load_params`. Hooray! You still can do so of course if you feel slightly masochistic. In fact, *better_launch* supports the full param syntax for mapping params to nodes, including namespace wildcards. See the [load_params](../../reference/better_launch/launcher/#better_launch.launcher.BetterLaunch.load_params) documentation for details. + + +## Logging +Just like ROS2 launch, *better_launch* takes care of managing loggers and redirecting everything where it belongs (in fact that part is largely copied from ROS2 launch). However, I did away with the in my opinion not very useful separation between a node's `stdout` and `stderr`, since nodes apparently write their log output to `stderr` by default. + +I also added a reformatting layer so that colors and nicer screen output are possible. The format can be customized by passing your own logging format strings to the [launch_this](../../reference/better_launch/wrapper/#better_launch.wrapper.launch_this) decorator. However, it is recommended to set the `BL_SCREEN_LOG_FORMAT` and `BL_FILE_LOG_FORMAT` environment variables instead. + + +## Abandoned processes +ROS2 launch has a bad reputation of leaving stale and abandoned processes behind after terminating. In my testing so far this has never been an issue with *better_launch* yet - except when you hard kill (-9) the *better_launch* process. diff --git a/src/lib/better_launch/docs/about/features.md b/src/lib/better_launch/docs/about/features.md new file mode 100644 index 0000000000..01abfe5dbf --- /dev/null +++ b/src/lib/better_launch/docs/about/features.md @@ -0,0 +1,147 @@ +# :puzzle_piece: Okay, what can I do with it? +Everything! *better_launch* can do everything ROS2 launch can do and some more. It's also faster, more reliabel, and comes with complete [API documentation](../reference/better_launch/index.md) and many [examples](https://github.com/dfki-ric/better_launch/tree/main/examples). + +- create *nodes*, *composers*, *components*, *namespaces*, etc. +- create *subscribers*, *publishers*, *services*, etc. on the fly +- *predictable execution* allows you to interact with nodes right after starting them +- include other *better_launch launchfiles* +- include *and be included* from regular ROS2 launchfiles +- manage *lifecycle stages* +- *remap topics* using simple dicts +- launch arguments *passed directly* to your function +- easily *locate files* and load configs +- manage *nodes from other launch files* +- manage your node using a nice [terminal UI](../howto/tui.md) reminiscent of [rosmon](https://github.com/xqms/rosmon) +- write [TOML launchfiles](../howto/toml.md) with *ROS1-style substitutions* +- all of this and more with as few as *only 2 imports* + +![TUI](../assets/images/tui_small1.png) + +For a quick comparison, bravely unfold the sections below: + +??? failure "ROS2" + + ```python + # Taken from https://docs.ros.org/en/jazzy/Tutorials/Intermediate/Launch/Using-Substitutions.html + from launch_ros.actions import Node + + from launch import LaunchDescription + from launch.actions import DeclareLaunchArgument, ExecuteProcess, TimerAction + from launch.conditions import IfCondition + from launch.substitutions import LaunchConfiguration, PythonExpression + + + def generate_launch_description(): + turtlesim_ns = LaunchConfiguration('turtlesim_ns') + use_provided_red = LaunchConfiguration('use_provided_red') + new_background_r = LaunchConfiguration('new_background_r') + + turtlesim_ns_launch_arg = DeclareLaunchArgument( + 'turtlesim_ns', + default_value='turtlesim1' + ) + use_provided_red_launch_arg = DeclareLaunchArgument( + 'use_provided_red', + default_value='False' + ) + new_background_r_launch_arg = DeclareLaunchArgument( + 'new_background_r', + default_value='200' + ) + + turtlesim_node = Node( + package='turtlesim', + namespace=turtlesim_ns, + executable='turtlesim_node', + name='sim' + ) + spawn_turtle = ExecuteProcess( + cmd=[[ + 'ros2 service call ', + turtlesim_ns, + '/spawn ', + 'turtlesim/srv/Spawn ', + '"{x: 2, y: 2, theta: 0.2}"' + ]], + shell=True + ) + change_background_r = ExecuteProcess( + cmd=[[ + 'ros2 param set ', + turtlesim_ns, + '/sim background_r ', + '120' + ]], + shell=True + ) + change_background_r_conditioned = ExecuteProcess( + condition=IfCondition( + PythonExpression([ + new_background_r, + ' == 200', + ' and ', + use_provided_red + ]) + ), + cmd=[[ + 'ros2 param set ', + turtlesim_ns, + '/sim background_r ', + new_background_r + ]], + shell=True + ) + + return LaunchDescription([ + turtlesim_ns_launch_arg, + use_provided_red_launch_arg, + new_background_r_launch_arg, + turtlesim_node, + spawn_turtle, + change_background_r, + TimerAction( + period=2.0, + actions=[change_background_r_conditioned], + ) + ]) + ``` + + +??? success "better_launch (python)" + + ```python + from better_launch import BetterLaunch, launch_this + from rclpy import Timer + + @launch_this + def my_start( + # Launch arguments in function signature + turtlesim_ns: str = "turtlesim1", + use_provided_red: bool = False, + new_background_r: int = 200, + ): + bl = BetterLaunch() + + # Pythonic AF + with bl.group(turtlesim_ns): + turtle_node = bl.node( + package="turtlesim", + executable="turtlesim_node", + name="sim", + # Pass parameters directly + params={"background_r": 120} + ) + + # Convenient API for common tasks + bl.call_service( + topic=f"/{turtlesim_ns}/spawn", + service_type="turtlesim/srv/Spawn", + # No weird types like passing dicts as strings + request_args={"x": 2.0, "y": 2.0, "theta": 0.2}, + ) + + if new_background_r == 200 and use_provided_red: + turtle_node.is_ros2_connected(timeout=None) + turtle_node.set_live_params({"background_r": new_background_r}) + ``` + diff --git a/src/lib/better_launch/docs/about/performance.md b/src/lib/better_launch/docs/about/performance.md new file mode 100644 index 0000000000..a9e5e0bd17 --- /dev/null +++ b/src/lib/better_launch/docs/about/performance.md @@ -0,0 +1,36 @@ +# :100: Performance +I am not an expert on profiling code. Even though *better_launch* uses synchronous calls (or classic threads if necessary), and does some additional work to reformat output from nodes, it was able to achieve similar performance to `ros2 launch`. The scripts and results from the benchmarks can be found under [benchmarks](https://github.com/dfki-ric/better_launch/tree/main/docs/benchmarks). This section will only show the most relevant parts. + + +???+ note + + `bl` is just a script to locate the launch file and then run it, so I decided to not use `bl` for these benchmarks and instead run the launch file directly; otherwise the resources used by the launch file will not be visible to most profilers. + + +??? example "memray" + + [memray](https://github.com/bloomberg/memray) reports that *better_launch* uses about 30% less memory than `ros2 launch`. + + | | better_launch | ros2 launch | + | ----------------- | ------------- | ----------- | + | allocations | 48196 | 60943 | + | peak memory usage | 6.6 MiB | 9.7 MiB | + | details | [link](../benchmarks/results/memray/memray-flamegraph-bl.html) | [link](../benchmarks/results/memray/memray-flamegraph-ros2.html) | + + +??? example "psutil" + + [psutil](https://psutil.readthedocs.io/en/latest/index.html#psutil.Process.memory_full_info) shows that *better_launch* uses more CPU in the beginning and more memory in total compared to `ros2 launch`. The memory reported is the unique set size (see the previous link). I'm not sure how these results relate to the memray statistics above. + + ![](../benchmarks/results/psutil/cpu_usage.png) + + ![](../benchmarks/results/psutil/memory_usage.png) + + +??? example "py-spy" + + I use [py-spy](https://github.com/benfred/py-spy) to see where *better_launch* is using resources that can still be optimized. The speedscope files can be visualized on [speedscope.app](https://www.speedscope.app/). + + ![](../benchmarks/results/pyspy/bl.svg) + + ![](../benchmarks/results/pyspy/ros2.svg) diff --git a/src/lib/better_launch/docs/about/ros2.md b/src/lib/better_launch/docs/about/ros2.md new file mode 100644 index 0000000000..8ea4c13c29 --- /dev/null +++ b/src/lib/better_launch/docs/about/ros2.md @@ -0,0 +1,63 @@ +# :thinking_face: Why not improve ROS2 launch? +Because I think it is beyond redemption and no amount of refactoring and REPs (ROS enhancement proposals) will turn the sails. While tools like [simple_launch](https://github.com/oKermorgant/simple_launch) or [launch-generator](https://github.com/Tacha-S/launch_generator/) exist, they still use ROS2 launch under the hood and so inherit much of its clunkiness. Rather than fixing an inherently broken solution, I decided to make a RAP - a ROS abandonment proposal :) + +Here is a "simple" launch file from the [official documentation](https://docs.ros.org/en/jazzy/Tutorials/Intermediate/Launch/Using-Substitutions.html) that does nothing but include another launch file: + +```python +from launch_ros.substitutions import FindPackageShare + +from launch import LaunchDescription +from launch.actions import IncludeLaunchDescription +from launch.launch_description_sources import PythonLaunchDescriptionSource +from launch.substitutions import PathJoinSubstitution, TextSubstitution + + +def generate_launch_description(): + colors = { + 'background_r': '200' + } + + return LaunchDescription([ + IncludeLaunchDescription( + PythonLaunchDescriptionSource([ + PathJoinSubstitution([ + FindPackageShare('launch_tutorial'), + 'launch', + 'example_substitutions_launch.py' + ]) + ]), + launch_arguments={ + 'turtlesim_ns': 'turtlesim2', + 'use_provided_red': 'True', + 'new_background_r': TextSubstitution(text=str(colors['background_r'])) + }.items() + ) + ]) +``` + +I think we can agree that this is not exactly elegant - including another launch file should be doable within a single line, not 10 plus 5 imports. Other terrible decisions within ROS2 launch include, but are not limited to: + +- a weird fetish for unintuitive import statements (see above) +- unneccesarily strict type checking (why use python if I have to verify everything?) +- nonsensical argument types (e.g. remaps are a *list of tuples* instead of simply a *dict*) +- using asyncio may be slightly faster, but prevents normal variable interactions (ever wondered why you always see these weird `Condition` classes instead of a simple `if my_arg:`?) +- horrendous API for starting lifecycle nodes (also, why the hell are there two completely separate base interfaces?) +- the list goes on... + +For comparison, here is what the above launch file will look like in *better_launch*: + +```python +from better_launch import BetterLaunch, launch_this + +@launch_this +def main(turtlesim_ns = "turtlesim2", use_provided_red = True, new_background_r = 200): + bl = BetterLaunch() + + bl.include( + "launch_tutorial", + "example_substitutions.launch.py", + pass_all_args=True, # or pass as keyword arguments + ) +``` + +Overall, ROS2 launch seems like a system architect's wet fever dream, and I don't enjoy it. diff --git a/src/lib/better_launch/docs/about/why.md b/src/lib/better_launch/docs/about/why.md new file mode 100644 index 0000000000..6e08810963 --- /dev/null +++ b/src/lib/better_launch/docs/about/why.md @@ -0,0 +1,32 @@ +# :compass: Why better_launch? + +*better_launch* is what I wish `ROS2 launch` would be: intuitive to use, simple to understand, easy to remember. It is **not** yet another abstraction layer over ROS2 launch; it is a **full replacement** with no required dependencies on the existing launch system. + +Instead of dozens of imports and class instances for even the most basic tasks, your launch files could look as simple and beautiful as this: + +```python +from better_launch import BetterLaunch, launch_this + +@launch_this(ui=True) +def my_main(enable_x: bool = True): + """ + This is how nice your launch files could be! + """ + bl = BetterLaunch() + + if enable_x: + bl.node( + "examples_rclpy_minimal_publisher", + "publisher_local_function", + "example_publisher", + ) + + # Include other launch files, even regular ROS2 launch files! + bl.include("better_launch", "ros2_turtlesim.launch.py") +``` + +```bash +$> bl my_package my_launch_file.py --enable_x True +``` + +*Do I have your attention? Read on to learn more!* diff --git a/src/lib/better_launch/docs/assets/images/dfki.png b/src/lib/better_launch/docs/assets/images/dfki.png new file mode 100644 index 0000000000..8b23f39a20 Binary files /dev/null and b/src/lib/better_launch/docs/assets/images/dfki.png differ diff --git a/src/lib/better_launch/docs/assets/images/dfki_white.png b/src/lib/better_launch/docs/assets/images/dfki_white.png new file mode 100644 index 0000000000..710079a413 Binary files /dev/null and b/src/lib/better_launch/docs/assets/images/dfki_white.png differ diff --git a/src/lib/better_launch/docs/assets/images/hero.png b/src/lib/better_launch/docs/assets/images/hero.png new file mode 100644 index 0000000000..52c02a5200 Binary files /dev/null and b/src/lib/better_launch/docs/assets/images/hero.png differ diff --git a/src/lib/better_launch/docs/assets/images/hero_corrvyd.jpg b/src/lib/better_launch/docs/assets/images/hero_corrvyd.jpg new file mode 100644 index 0000000000..169fc13b61 Binary files /dev/null and b/src/lib/better_launch/docs/assets/images/hero_corrvyd.jpg differ diff --git a/src/lib/better_launch/docs/assets/images/hero_large.jpg b/src/lib/better_launch/docs/assets/images/hero_large.jpg new file mode 100644 index 0000000000..4588898204 Binary files /dev/null and b/src/lib/better_launch/docs/assets/images/hero_large.jpg differ diff --git a/src/lib/better_launch/docs/assets/images/logo.png b/src/lib/better_launch/docs/assets/images/logo.png new file mode 100644 index 0000000000..e39daf10fc Binary files /dev/null and b/src/lib/better_launch/docs/assets/images/logo.png differ diff --git a/src/lib/better_launch/docs/assets/images/logo.svg b/src/lib/better_launch/docs/assets/images/logo.svg new file mode 100644 index 0000000000..2858e986c3 --- /dev/null +++ b/src/lib/better_launch/docs/assets/images/logo.svg @@ -0,0 +1,136 @@ + + + + diff --git a/src/lib/better_launch/docs/assets/images/logo_template.png b/src/lib/better_launch/docs/assets/images/logo_template.png new file mode 100644 index 0000000000..eca76dc0db Binary files /dev/null and b/src/lib/better_launch/docs/assets/images/logo_template.png differ diff --git a/src/lib/better_launch/docs/assets/images/logo_text.png b/src/lib/better_launch/docs/assets/images/logo_text.png new file mode 100644 index 0000000000..0bbe07c244 Binary files /dev/null and b/src/lib/better_launch/docs/assets/images/logo_text.png differ diff --git a/src/lib/better_launch/docs/assets/images/logo_text.svg b/src/lib/better_launch/docs/assets/images/logo_text.svg new file mode 100644 index 0000000000..af34323077 --- /dev/null +++ b/src/lib/better_launch/docs/assets/images/logo_text.svg @@ -0,0 +1,160 @@ + + + +better launch_ diff --git a/src/lib/better_launch/docs/assets/images/tui_large.png b/src/lib/better_launch/docs/assets/images/tui_large.png new file mode 100644 index 0000000000..309abd98b1 Binary files /dev/null and b/src/lib/better_launch/docs/assets/images/tui_large.png differ diff --git a/src/lib/better_launch/docs/assets/images/tui_loglevel.png b/src/lib/better_launch/docs/assets/images/tui_loglevel.png new file mode 100644 index 0000000000..c3c0b50463 Binary files /dev/null and b/src/lib/better_launch/docs/assets/images/tui_loglevel.png differ diff --git a/src/lib/better_launch/docs/assets/images/tui_main.png b/src/lib/better_launch/docs/assets/images/tui_main.png new file mode 100644 index 0000000000..874d0bec83 Binary files /dev/null and b/src/lib/better_launch/docs/assets/images/tui_main.png differ diff --git a/src/lib/better_launch/docs/assets/images/tui_node_ctrl.png b/src/lib/better_launch/docs/assets/images/tui_node_ctrl.png new file mode 100644 index 0000000000..0f316667e3 Binary files /dev/null and b/src/lib/better_launch/docs/assets/images/tui_node_ctrl.png differ diff --git a/src/lib/better_launch/docs/assets/images/tui_node_info.png b/src/lib/better_launch/docs/assets/images/tui_node_info.png new file mode 100644 index 0000000000..2f7b7400c0 Binary files /dev/null and b/src/lib/better_launch/docs/assets/images/tui_node_info.png differ diff --git a/src/lib/better_launch/docs/assets/images/tui_search.png b/src/lib/better_launch/docs/assets/images/tui_search.png new file mode 100644 index 0000000000..f7ee5be01e Binary files /dev/null and b/src/lib/better_launch/docs/assets/images/tui_search.png differ diff --git a/src/lib/better_launch/docs/benchmarks/cprofile-bl.bash b/src/lib/better_launch/docs/benchmarks/cprofile-bl.bash new file mode 100755 index 0000000000..29253c5199 --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/cprofile-bl.bash @@ -0,0 +1,9 @@ +#!/bin/bash + +# NOTE we can't profile bl because the bl script uses execvp to replace its process. +# We profile the launch file instead, as bl is just a way to find and run the launch file. + +python -m cProfile -o ./results/cprofile/bl.profile ../../examples/11_performance.launch.py +gprof2dot -f pstats ./results/cprofile/bl.profile -o ./results/cprofile/bl.dot +dot -Tsvg ./results/cprofile/bl.dot -o ./results/cprofile/bl.svg +rm ./results/cprofile/bl.dot diff --git a/src/lib/better_launch/docs/benchmarks/cprofile-ros2.bash b/src/lib/better_launch/docs/benchmarks/cprofile-ros2.bash new file mode 100755 index 0000000000..37b28f3c2a --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/cprofile-ros2.bash @@ -0,0 +1,5 @@ +#!/bin/bash +python -m cProfile -o ./results/cprofile/ros2.profile /opt/ros/humble/bin/ros2 launch better_launch ros2_performance.launch.py +gprof2dot -f pstats ./results/cprofile/ros2.profile -o ./results/cprofile/ros2.dot +dot -Tsvg ./results/cprofile/ros2.dot -o ./results/cprofile/ros2.svg +rm ./results/cprofile/ros2.dot diff --git a/src/lib/better_launch/docs/benchmarks/memray-bl.bash b/src/lib/better_launch/docs/benchmarks/memray-bl.bash new file mode 100755 index 0000000000..ffb7a92614 --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/memray-bl.bash @@ -0,0 +1,9 @@ +#!/usr/bin/bash + +# Generates memray-bl.bin and the corresponding plot +# Memray shows similar memory usage for better_launch and ros2 + +rm -f ./results/memray/memray-bl.bin ./results/memray/memray-flamegraph-bl.html +memray run -o ./results/memray/memray-bl.bin ../../examples/11_performance.launch.py +memray flamegraph ./results/memray/memray-bl.bin +firefox ./results/memray/memray-flamegraph-bl.html diff --git a/src/lib/better_launch/docs/benchmarks/memray-ros2.bash b/src/lib/better_launch/docs/benchmarks/memray-ros2.bash new file mode 100755 index 0000000000..f0fdaa3ddd --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/memray-ros2.bash @@ -0,0 +1,9 @@ +#!/usr/bin/bash + +# Generates memray-ros2.bin and the corresponding plot +# Memray shows similar memory usage for better_launch and ros2 + +rm -f ./results/memray/memray-ros2.bin ./results/memray/memray-flamegraph-ros2.html +memray run -o ./results/memray/memray-ros2.bin /opt/ros/humble/bin/ros2 launch better_launch ros2_performance.launch.py +memray flamegraph ./results/memray/memray-ros2.bin +firefox ./results/memray/memray-flamegraph-ros2.html diff --git a/src/lib/better_launch/docs/benchmarks/psutil_benchmark.py b/src/lib/better_launch/docs/benchmarks/psutil_benchmark.py new file mode 100755 index 0000000000..4f8102e096 --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/psutil_benchmark.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 + +"""Generates reports/psutil_bl.csv and reports/psutil_ros2.csv + +Memory and CPU usage is measured using psutil.Popen. This method will record significantly less +memory for ROS2, possibly due to only counting residential set memory. + +Use plot.py to generate the plots from the recorded csv files. + +NOTE: make sure the referenced launch files are installed in your workspace and that the +workspace is sourced! + +Usage: + python psutil_benchmark.py bl + python psutil_benchmark.py ros2 +""" + +import sys +import os +import psutil +import signal +import time +import csv + + +COMMAND_BL = ["python", "../../examples/11_performance.launch.py"] +COMMAND_ROS2 = ["ros2", "launch", "better_launch", "ros2_performance.launch.py"] +INTERVAL = 0.1 +OUTPUT_NAME_FMT = "results/psutil/psutil_%s.csv" + + +def monitor_process(proc: psutil.Popen, output: str, interval: float = 0.1): + start = time.time() + + with open(output, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(["time_s", "cpu_%", "memory_mb"]) + try: + while proc.is_running(): + cpu = proc.cpu_percent(interval=interval) + mem_info = proc.memory_full_info() + mem = (mem_info.uss) / (1024 * 1024) + + writer.writerow([time.time() - start, cpu, mem]) + + if time.time() >= start + 10.0: + proc.send_signal(signal.SIGINT) + break + except psutil.NoSuchProcess: + print("Process ended") + + time.sleep(1.0) + proc.terminate() + time.sleep(1.0) + proc.kill() + + +if __name__ == "__main__": + """Usage: + * `psutil_benchmark.py bl` + * `psutil_benchmark.py ros2` + * `psutil_plots.py` + """ + if len(sys.argv) < 2: + raise ValueError("No mode was passed") + + mode = sys.argv[1] + + if mode == "bl": + cmd = COMMAND_BL + elif mode == "ros2": + cmd = COMMAND_ROS2 + else: + raise ValueError("Invalid mode") + + process = psutil.Popen(cmd) + output = OUTPUT_NAME_FMT % cmd[0] + path = os.path.join(os.path.dirname(__file__), output) + monitor_process(process, path, INTERVAL) diff --git a/src/lib/better_launch/docs/benchmarks/psutil_plots.py b/src/lib/better_launch/docs/benchmarks/psutil_plots.py new file mode 100755 index 0000000000..85d076d0f1 --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/psutil_plots.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +"""Generates the plots from bl_profile.csv and ros2_profile.csv +""" + +import os +import plotly.graph_objects as go +import pandas as pd + + +BENCHMARK_BL = "results/psutil/psutil_bl.csv" +BENCHMARK_ROS2 = "results/psutil/psutil_ros2.csv" + + +def plot_usage(bl_csv: str, ros2_csv: str): + df1 = pd.read_csv(bl_csv) + df2 = pd.read_csv(ros2_csv) + + fig_cpu = go.Figure() + fig_cpu.add_trace( + go.Scatter(x=df1["time_s"], y=df1["cpu_%"], mode="lines", name="better_launch") + ) + fig_cpu.add_trace( + go.Scatter(x=df2["time_s"], y=df2["cpu_%"], mode="lines", name="ros2") + ) + fig_cpu.update_layout( + title="CPU Usage Comparison", xaxis_title="time_s", yaxis_title="cpu_%" + ) + + fig_mem = go.Figure() + fig_mem.add_trace( + go.Scatter(x=df1["time_s"], y=df1["memory_mb"], mode="lines", name="better_launch") + ) + fig_mem.add_trace( + go.Scatter(x=df2["time_s"], y=df2["memory_mb"], mode="lines", name="ros2") + ) + fig_mem.update_layout( + title="Memory Usage Comparison", xaxis_title="time_s", yaxis_title="memory_mb" + ) + + fig_cpu.show() + fig_mem.show() + + +if __name__ == "__main__": + plot_usage( + os.path.join(os.path.dirname(__file__), BENCHMARK_BL), + os.path.join(os.path.dirname(__file__), BENCHMARK_ROS2), + ) diff --git a/src/lib/better_launch/docs/benchmarks/pyspy-bl-speedscope.bash b/src/lib/better_launch/docs/benchmarks/pyspy-bl-speedscope.bash new file mode 100755 index 0000000000..9f29af8ebd --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/pyspy-bl-speedscope.bash @@ -0,0 +1,2 @@ +#!/bin/bash +py-spy record --format speedscope -o ./results/pyspy/speedscope-bl.json -- python ../../examples/11_performance.launch.py diff --git a/src/lib/better_launch/docs/benchmarks/pyspy-bl.bash b/src/lib/better_launch/docs/benchmarks/pyspy-bl.bash new file mode 100755 index 0000000000..d2470ed8d6 --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/pyspy-bl.bash @@ -0,0 +1,2 @@ +#!/bin/bash +py-spy record --format flamegraph -o ./results/pyspy/bl.svg -- python ../../examples/11_performance.launch.py \ No newline at end of file diff --git a/src/lib/better_launch/docs/benchmarks/pyspy-ros2-speedscope.bash b/src/lib/better_launch/docs/benchmarks/pyspy-ros2-speedscope.bash new file mode 100755 index 0000000000..25b50c984d --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/pyspy-ros2-speedscope.bash @@ -0,0 +1,2 @@ +#!/bin/bash +py-spy record --format speedscope -o ./results/pyspy/speedscope-ros2.json -- ros2 launch better_launch ros2_performance.launch.py diff --git a/src/lib/better_launch/docs/benchmarks/pyspy-ros2.bash b/src/lib/better_launch/docs/benchmarks/pyspy-ros2.bash new file mode 100755 index 0000000000..d0287dcc02 --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/pyspy-ros2.bash @@ -0,0 +1,2 @@ +#!/bin/bash +py-spy record --format flamegraph -o ./results/pyspy/ros2.svg -- ros2 launch better_launch ros2_performance.launch.py \ No newline at end of file diff --git a/src/lib/better_launch/docs/benchmarks/results/cprofile/bl.profile b/src/lib/better_launch/docs/benchmarks/results/cprofile/bl.profile new file mode 100644 index 0000000000..b55dd77b8c Binary files /dev/null and b/src/lib/better_launch/docs/benchmarks/results/cprofile/bl.profile differ diff --git a/src/lib/better_launch/docs/benchmarks/results/cprofile/bl.svg b/src/lib/better_launch/docs/benchmarks/results/cprofile/bl.svg new file mode 100644 index 0000000000..f0a8355521 --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/results/cprofile/bl.svg @@ -0,0 +1,700 @@ + + + + + + + + + +132 + + +node:1:<module> +0.65% +(0.00%) + + + + + + +816 + + +<frozen importlib:1022:_find_and_load +2.85% +(0.01%) +294× + + + + + +132->816 + + +0.63% +15× + + + +425 + + +<frozen importlib:987:_find_and_load_unlocked +2.85% +(0.01%) +294× + + + + + +816->425 + + +2.85% + + + + +158 + + +launcher:1:<module> +2.56% +(0.00%) + + + + + + +158->816 + + +2.55% +12× + + + +167 + + +~:0:<built-in method builtins.__import__> +0.97% +(0.00%) +53× + + + + + +167->816 + + +0.96% +35× + + + +168 + + +<frozen importlib:233:_call_with_frames_removed +2.84% +(0.00%) +456× + + + + + +168->167 + + +0.97% +35× + + + +190 + + +~:0:<built-in method builtins.compile> +0.78% +(0.78%) +109× + + + + + +168->190 + + +0.77% +84× + + + +199 + + +~:0:<built-in method builtins.exec> +100.00% +(0.01%) +251× + + + + + +168->199 + + +2.84% + + + + +199->132 + + +0.65% + + + + +199->158 + + +2.56% + + + + +921 + + +11_performance.launch:1:<module> +100.00% +(0.00%) + + + + + + +199->921 + + +100.00% + + + + +1348 + + +__init__:1:<module> +0.81% +(0.00%) + + + + + + +199->1348 + + +0.81% + + + + +1953 + + +__init__:1:<module> +2.84% +(0.00%) + + + + + + +199->1953 + + +2.84% + + + + +174 + + +launcher:223:spin +96.46% +(0.02%) + + + + + + +1759 + + +_base:430:result +96.36% +(0.02%) +58× + + + + + +174->1759 + + +96.36% +52× + + + +533 + + +threading:288:wait +96.33% +(0.02%) +54× + + + + + +1759->533 + + +96.33% +52× + + + +921->816 + + +2.85% + + + + +920 + + +wrapper:27:launch_this +97.15% +(0.00%) + + + + + + +921->920 + + +97.15% + + + + +1348->816 + + +0.81% + + + + +1953->816 + + +2.79% + + + + +242 + + +<frozen importlib:664:_load_unlocked +2.84% +(0.01%) +284× + + + + + +2048 + + +<frozen importlib:877:exec_module +2.84% +(0.01%) +249× + + + + + +242->2048 + + +2.84% + + + + +2048->168 + + +2.84% + + + + +404 + + +<frozen importlib:950:get_code +1.15% +(0.02%) +249× + + + + + +2048->404 + + +1.15% +249× + + + +243 + + +<frozen importlib:1053:_handle_fromlist +0.53% +(0.00%) +161× + + + + + +243->168 + + +0.51% +24× + + + +258 + + +core:737:invoke +96.57% +(0.00%) + + + + + + +929 + + +decorators:32:new_func +96.57% +(0.00%) + + + + + + +258->929 + + +96.57% + + + + +928 + + +wrapper:280:run +96.57% +(0.00%) + + + + + + +929->928 + + +96.57% + + + + +398 + + +inspect:1604:getframeinfo +0.50% +(0.00%) +56× + + + + + +2049 + + +<frozen importlib:942:source_to_code +0.78% +(0.00%) +84× + + + + + +404->2049 + + +0.78% +84× + + + +2049->168 + + +0.77% +84× + + + +418 + + +wrapper:83:_launch_this_wrapper +97.15% +(0.00%) + + + + + + +670 + + +core:1014:main +96.58% +(0.00%) + + + + + + +418->670 + + +96.58% + + + + +940 + + +core:1432:invoke +96.57% +(0.00%) + + + + + + +670->940 + + +96.57% + + + + +425->168 + + +0.46% +12× + + + +425->242 + + +2.84% + + + + +529 + + +~:0:<method 'acquire' of '_thread.lock' objects> +96.30% +(96.30%) +167× + + + + + +533->529 + + +96.30% +108× + + + +940->258 + + +96.57% + + + + +677 + + +inspect:1671:stack +0.51% +(0.00%) + + + + + + +852 + + +inspect:1643:getouterframes +0.51% +(0.00%) + + + + + + +677->852 + + +0.51% + + + + +852->398 + + +0.50% +56× + + + +919 + + +wrapper:68:decoration_helper +97.15% +(0.00%) + + + + + + +919->418 + + +97.15% + + + + +920->919 + + +97.15% + + + + +927 + + +wrapper:287:launch_func_wrapper +96.57% +(0.00%) + + + + + + +927->174 + + +96.46% + + + + +928->927 + + +96.57% + + + + diff --git a/src/lib/better_launch/docs/benchmarks/results/cprofile/ros2.profile b/src/lib/better_launch/docs/benchmarks/results/cprofile/ros2.profile new file mode 100644 index 0000000000..0062e0e947 Binary files /dev/null and b/src/lib/better_launch/docs/benchmarks/results/cprofile/ros2.profile differ diff --git a/src/lib/better_launch/docs/benchmarks/results/cprofile/ros2.svg b/src/lib/better_launch/docs/benchmarks/results/cprofile/ros2.svg new file mode 100644 index 0000000000..8db31f68b7 --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/results/cprofile/ros2.svg @@ -0,0 +1,1484 @@ + + + + + + + + + +0 + + +~:0:<method 'run' of '_contextvars.Context' objects> +1.84% +(0.01%) +222× + + + + + +1313 + + +launch_service:228:_process_one_event +1.57% +(0.00%) +45× + + + + + +0->1313 + + +1.57% +45× + + + +430 + + +launch_service:232:__process_event +1.56% +(0.02%) +25× + + + + + +1313->430 + + +1.56% +25× + + + +1 + + +events:78:_run +1.85% +(0.01%) +222× + + + + + +1->0 + + +1.84% +222× + + + +2 + + +~:0:<built-in method builtins.__build_class__> +0.66% +(0.19%) +1051× + + + + + +149 + + +parser:1:<module> +0.53% +(0.00%) + + + + + + +1074 + + +<frozen importlib:1022:_find_and_load +4.05% +(0.03%) +470× + + + + + +149->1074 + + +0.52% + + + + +635 + + +<frozen importlib:987:_find_and_load_unlocked +4.04% +(0.02%) +470× + + + + + +1074->635 + + +4.04% + + + + +150 + + +parse_substitution:1:<module> +0.52% +(0.00%) + + + + + + +150->1074 + + +0.52% + + + + +186 + + +entity:1:<module> +0.61% +(0.00%) + + + + + + +186->1074 + + +0.60% + + + + +202 + + +launch:1:<module> +2.04% +(0.00%) + + + + + + +202->1074 + + +2.04% + + + + +210 + + +declare_launch_argument:1:<module> +1.20% +(0.00%) + + + + + + +210->1074 + + +1.19% + + + + +211 + + +__init__:1:<module> +1.19% +(0.00%) + + + + + + +211->1074 + + +1.18% + + + + +297 + + +~:0:<built-in method builtins.__import__> +2.87% +(0.00%) +64× + + + + + +297->1074 + + +2.87% +14× + + + +298 + + +<frozen importlib:233:_call_with_frames_removed +4.03% +(0.01%) +619× + + + + + +298->297 + + +2.87% +14× + + + +337 + + +~:0:<built-in method builtins.compile> +0.65% +(0.65%) +101× + + + + + +298->337 + + +0.64% +64× + + + +352 + + +~:0:<built-in method builtins.exec> +100.00% +(0.01%) +423× + + + + + +298->352 + + +4.01% + + + + +352->149 + + +0.53% + + + + +352->150 + + +0.52% + + + + +352->186 + + +0.61% + + + + +352->202 + + +2.04% + + + + +352->210 + + +1.20% + + + + +352->211 + + +1.19% + + + + +386 + + +ros2:1:<module> +100.00% +(0.00%) + + + + + + +352->386 + + +100.00% + + + + +2055 + + +__init__:1:<module> +1.16% +(0.00%) + + + + + + +352->2055 + + +1.16% + + + + +2056 + + +type_utils:1:<module> +0.61% +(0.00%) + + + + + + +352->2056 + + +0.61% + + + + +2187 + + +__init__:1:<module> +1.95% +(0.00%) + + + + + + +352->2187 + + +1.95% + + + + +2188 + + +__init__:1:<module> +1.44% +(0.00%) + + + + + + +352->2188 + + +1.44% + + + + +2189 + + +__init__:1:<module> +1.45% +(0.00%) + + + + + + +352->2189 + + +1.45% + + + + +2574 + + +cli:1:<module> +1.04% +(0.00%) + + + + + + +352->2574 + + +1.04% + + + + +386->1074 + + +0.50% + + + + +365 + + +cli:27:main +98.41% +(0.00%) + + + + + + +386->365 + + +98.41% + + + + +622 + + +ros2:18:importlib_load_entry_point +1.08% +(0.00%) + + + + + + +386->622 + + +1.08% + + + + +2055->1074 + + +0.55% + + + + +416 + + +<frozen importlib:1053:_handle_fromlist +2.34% +(0.01%) +546× + + + + + +2055->416 + + +0.61% + + + + +2056->1074 + + +0.61% + + + + +2187->1074 + + +1.95% + + + + +2188->1074 + + +1.43% +14× + + + +2189->416 + + +1.44% + + + + +2574->1074 + + +1.04% + + + + +354 + + +python_launch_file_utilities:41:get_launch_description_from_python_launch_file +0.90% +(0.00%) + + + + + + +361 + + +__init__:144:add_subparsers_on_demand +3.56% +(0.00%) + + + + + + +399 + + +entry_points:57:get_entry_points +1.39% +(0.00%) + + + + + + +361->399 + + +0.54% + + + + +1087 + + +__init__:56:get_command_extensions +2.51% +(0.00%) + + + + + + +361->1087 + + +2.51% + + + + +805 + + +__init__:999:entry_points +2.14% +(0.00%) + + + + + + +399->805 + + +1.35% + + + + +1089 + + +plugin_system:37:instantiate_extensions +2.91% +(0.00%) + + + + + + +1087->1089 + + +2.51% + + + + +365->361 + + +3.56% + + + + +1133 + + +launch:125:main +94.84% +(0.00%) + + + + + + +365->1133 + + +94.84% + + + + +637 + + +api:141:launch_a_launch_file +94.82% +(0.00%) + + + + + + +1133->637 + + +94.82% + + + + +834 + + +__init__:165:load +3.09% +(0.00%) + + + + + + +622->834 + + +1.04% + + + + +639 + + +__init__:456:load +2.14% +(0.00%) + + + + + + +805->639 + + +2.14% + + + + +415 + + +<frozen importlib:664:_load_unlocked +4.02% +(0.02%) +454× + + + + + +3039 + + +<frozen importlib:877:exec_module +4.03% +(0.01%) +422× + + + + + +415->3039 + + +4.02% + + + + +3039->298 + + +4.01% + + + + +612 + + +<frozen importlib:950:get_code +1.24% +(0.04%) +422× + + + + + +3039->612 + + +1.24% +422× + + + +416->298 + + +2.31% +11× + + + +418 + + +<frozen importlib:1399:_get_spec +0.52% +(0.03%) +462× + + + + + +1116 + + +visit_all_entities_and_collect_futures_impl:25:visit_all_entities_and_collect_futures +0.97% +(0.00%) +10× + + + + + +430->1116 + + +0.97% + + + + +1116->1116 + + +0.94% + + + + +1150 + + +action:104:visit +0.97% +(0.00%) + + + + + + +1116->1150 + + +0.97% + + + + +565 + + +base_events:1832:_run_once +94.80% +(0.03%) +149× + + + + + +565->1 + + +1.85% +222× + + + +576 + + +selectors:452:select +92.89% +(0.02%) +149× + + + + + +565->576 + + +92.89% +149× + + + +2651 + + +~:0:<method 'poll' of 'select.epoll' objects> +92.87% +(92.90%) +149× + + + + + +576->2651 + + +92.87% +149× + + + +3040 + + +<frozen importlib:942:source_to_code +0.64% +(0.00%) +64× + + + + + +612->3040 + + +0.64% +64× + + + +3040->298 + + +0.64% +64× + + + +1009 + + +__init__:108:import_module +3.09% +(0.00%) + + + + + + +834->1009 + + +3.09% + + + + +635->298 + + +2.51% +13× + + + +635->415 + + +4.02% + + + + +3012 + + +<frozen importlib:921:_find_spec +0.61% +(0.04%) +464× + + + + + +635->3012 + + +0.61% +464× + + + +3068 + + +<frozen importlib:1431:find_spec +0.53% +(0.01%) +462× + + + + + +3012->3068 + + +0.53% +462× + + + +636 + + +~:0:<built-in method builtins.sorted> +2.13% +(0.02%) +11× + + + + + +2560 + + +__init__:1018:<genexpr> +2.12% +(0.03%) +2080× + + + + + +636->2560 + + +2.12% +2080× + + + +1050 + + +_itertools:4:unique_everseen +0.80% +(0.05%) +2080× + + + + + +2560->1050 + + +0.80% +2080× + + + +2775 + + +__init__:629:entry_points +1.29% +(0.03%) +2076× + + + + + +2560->2775 + + +1.29% +2075× + + + +1317 + + +launch_service:357:run +94.81% +(0.00%) + + + + + + +637->1317 + + +94.81% + + + + +2036 + + +base_events:613:run_until_complete +94.80% +(0.00%) + + + + + + +1317->2036 + + +94.80% + + + + +639->636 + + +2.13% + + + + +775 + + +base_events:589:run_forever +94.80% +(0.00%) + + + + + + +775->565 + + +94.80% +149× + + + +3049 + + +<frozen importlib:1038:_gcd_import +3.09% +(0.00%) + + + + + + +1009->3049 + + +3.09% + + + + +3049->1074 + + +3.09% + + + + +2550 + + +__init__:934:_normalized_name +0.60% +(0.07%) +2175× + + + + + +1050->2550 + + +0.60% +2175× + + + +1094 + + +entry_points:77:load_entry_points +2.91% +(0.00%) + + + + + + +1089->1094 + + +2.91% + + + + +1094->399 + + +0.85% + + + + +1094->834 + + +2.05% + + + + +1488 + + +include_launch_description:146:execute +0.90% +(0.00%) + + + + + + +1150->1488 + + +0.90% + + + + +1129 + + +any_launch_file_utilities:28:get_launch_description_from_any_launch_file +0.90% +(0.00%) + + + + + + +1129->354 + + +0.90% + + + + +1291 + + +launch_description_source:77:get_launch_description +0.90% +(0.00%) + + + + + + +1488->1291 + + +0.90% + + + + +1290 + + +any_launch_description_source:51:_get_launch_description +0.90% +(0.00%) + + + + + + +1290->1129 + + +0.90% + + + + +1291->1290 + + +0.90% + + + + +2036->775 + + +94.80% + + + + +2774 + + +__init__:379:_from_text_for +0.52% +(0.04%) +2076× + + + + + +2775->2774 + + +0.52% +2076× + + + +2804 + + +__init__:919:read_text +0.74% +(0.08%) +2076× + + + + + +2775->2804 + + +0.74% +2076× + + + +3068->418 + + +0.52% +462× + + + diff --git a/src/lib/better_launch/docs/benchmarks/results/memray/memray-bl.bin b/src/lib/better_launch/docs/benchmarks/results/memray/memray-bl.bin new file mode 100644 index 0000000000..8355d1d353 Binary files /dev/null and b/src/lib/better_launch/docs/benchmarks/results/memray/memray-bl.bin differ diff --git a/src/lib/better_launch/docs/benchmarks/results/memray/memray-flamegraph-bl.html b/src/lib/better_launch/docs/benchmarks/results/memray/memray-flamegraph-bl.html new file mode 100644 index 0000000000..b214dd8003 --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/results/memray/memray-flamegraph-bl.html @@ -0,0 +1,339 @@ + + + + + + + + + memray - flamegraph report + + + + + + + + + + + + +
+
+
+ + + +
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/lib/better_launch/docs/benchmarks/results/memray/memray-flamegraph-ros2.html b/src/lib/better_launch/docs/benchmarks/results/memray/memray-flamegraph-ros2.html new file mode 100644 index 0000000000..9d6631aae2 --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/results/memray/memray-flamegraph-ros2.html @@ -0,0 +1,339 @@ + + + + + + + + + memray - flamegraph report + + + + + + + + + + + + +
+
+
+ + + +
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/lib/better_launch/docs/benchmarks/results/memray/memray-ros2.bin b/src/lib/better_launch/docs/benchmarks/results/memray/memray-ros2.bin new file mode 100644 index 0000000000..89896562d6 Binary files /dev/null and b/src/lib/better_launch/docs/benchmarks/results/memray/memray-ros2.bin differ diff --git a/src/lib/better_launch/docs/benchmarks/results/psutil/cpu_usage.html b/src/lib/better_launch/docs/benchmarks/results/psutil/cpu_usage.html new file mode 100644 index 0000000000..860869edca --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/results/psutil/cpu_usage.html @@ -0,0 +1,125 @@ + + + +
+
0.511.522.533.544.50100200300400500600700
better_launchros2CPU Usage Comparisontime_scpu_%
+ + \ No newline at end of file diff --git a/src/lib/better_launch/docs/benchmarks/results/psutil/cpu_usage.png b/src/lib/better_launch/docs/benchmarks/results/psutil/cpu_usage.png new file mode 100644 index 0000000000..c0b2d31df5 Binary files /dev/null and b/src/lib/better_launch/docs/benchmarks/results/psutil/cpu_usage.png differ diff --git a/src/lib/better_launch/docs/benchmarks/results/psutil/cpu_usage_files/MathJax.js b/src/lib/better_launch/docs/benchmarks/results/psutil/cpu_usage_files/MathJax.js new file mode 100644 index 0000000000..c54a1ed2d3 --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/results/psutil/cpu_usage_files/MathJax.js @@ -0,0 +1,19 @@ +/* + * /MathJax.js + * + * Copyright (c) 2009-2018 The MathJax Consortium + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +if(document.getElementById&&document.childNodes&&document.createElement){if(!(window.MathJax&&MathJax.Hub)){if(window.MathJax){window.MathJax={AuthorConfig:window.MathJax}}else{window.MathJax={}}MathJax.isPacked=true;MathJax.version="2.7.5";MathJax.fileversion="2.7.5";MathJax.cdnVersion="2.7.5";MathJax.cdnFileVersions={};(function(d){var b=window[d];if(!b){b=window[d]={}}var e=[];var c=function(f){var g=f.constructor;if(!g){g=function(){}}for(var h in f){if(h!=="constructor"&&f.hasOwnProperty(h)){g[h]=f[h]}}return g};var a=function(){return function(){return arguments.callee.Init.call(this,arguments)}};b.Object=c({constructor:a(),Subclass:function(f,h){var g=a();g.SUPER=this;g.Init=this.Init;g.Subclass=this.Subclass;g.Augment=this.Augment;g.protoFunction=this.protoFunction;g.can=this.can;g.has=this.has;g.isa=this.isa;g.prototype=new this(e);g.prototype.constructor=g;g.Augment(f,h);return g},Init:function(f){var g=this;if(f.length===1&&f[0]===e){return g}if(!(g instanceof f.callee)){g=new f.callee(e)}return g.Init.apply(g,f)||g},Augment:function(f,g){var h;if(f!=null){for(h in f){if(f.hasOwnProperty(h)){this.protoFunction(h,f[h])}}if(f.toString!==this.prototype.toString&&f.toString!=={}.toString){this.protoFunction("toString",f.toString)}}if(g!=null){for(h in g){if(g.hasOwnProperty(h)){this[h]=g[h]}}}return this},protoFunction:function(g,f){this.prototype[g]=f;if(typeof f==="function"){f.SUPER=this.SUPER.prototype}},prototype:{Init:function(){},SUPER:function(f){return f.callee.SUPER},can:function(f){return typeof(this[f])==="function"},has:function(f){return typeof(this[f])!=="undefined"},isa:function(f){return(f instanceof Object)&&(this instanceof f)}},can:function(f){return this.prototype.can.call(this,f)},has:function(f){return this.prototype.has.call(this,f)},isa:function(g){var f=this;while(f){if(f===g){return true}else{f=f.SUPER}}return false},SimpleSUPER:c({constructor:function(f){return this.SimpleSUPER.define(f)},define:function(f){var h={};if(f!=null){for(var g in f){if(f.hasOwnProperty(g)){h[g]=this.wrap(g,f[g])}}if(f.toString!==this.prototype.toString&&f.toString!=={}.toString){h.toString=this.wrap("toString",f.toString)}}return h},wrap:function(i,h){if(typeof(h)!=="function"||!h.toString().match(/\.\s*SUPER\s*\(/)){return h}var g=function(){this.SUPER=g.SUPER[i];try{var f=h.apply(this,arguments)}catch(j){delete this.SUPER;throw j}delete this.SUPER;return f};g.toString=function(){return h.toString.apply(h,arguments)};return g}})});b.Object.isArray=Array.isArray||function(f){return Object.prototype.toString.call(f)==="[object Array]"};b.Object.Array=Array})("MathJax");(function(BASENAME){var BASE=window[BASENAME];if(!BASE){BASE=window[BASENAME]={}}var isArray=BASE.Object.isArray;var CALLBACK=function(data){var cb=function(){return arguments.callee.execute.apply(arguments.callee,arguments)};for(var id in CALLBACK.prototype){if(CALLBACK.prototype.hasOwnProperty(id)){if(typeof(data[id])!=="undefined"){cb[id]=data[id]}else{cb[id]=CALLBACK.prototype[id]}}}cb.toString=CALLBACK.prototype.toString;return cb};CALLBACK.prototype={isCallback:true,hook:function(){},data:[],object:window,execute:function(){if(!this.called||this.autoReset){this.called=!this.autoReset;return this.hook.apply(this.object,this.data.concat([].slice.call(arguments,0)))}},reset:function(){delete this.called},toString:function(){return this.hook.toString.apply(this.hook,arguments)}};var ISCALLBACK=function(f){return(typeof(f)==="function"&&f.isCallback)};var EVAL=function(code){return eval.call(window,code)};var TESTEVAL=function(){EVAL("var __TeSt_VaR__ = 1");if(window.__TeSt_VaR__){try{delete window.__TeSt_VaR__}catch(error){window.__TeSt_VaR__=null}}else{if(window.execScript){EVAL=function(code){BASE.__code=code;code="try {"+BASENAME+".__result = eval("+BASENAME+".__code)} catch(err) {"+BASENAME+".__result = err}";window.execScript(code);var result=BASE.__result;delete BASE.__result;delete BASE.__code;if(result instanceof Error){throw result}return result}}else{EVAL=function(code){BASE.__code=code;code="try {"+BASENAME+".__result = eval("+BASENAME+".__code)} catch(err) {"+BASENAME+".__result = err}";var head=(document.getElementsByTagName("head"))[0];if(!head){head=document.body}var script=document.createElement("script");script.appendChild(document.createTextNode(code));head.appendChild(script);head.removeChild(script);var result=BASE.__result;delete BASE.__result;delete BASE.__code;if(result instanceof Error){throw result}return result}}}TESTEVAL=null};var USING=function(args,i){if(arguments.length>1){if(arguments.length===2&&!(typeof arguments[0]==="function")&&arguments[0] instanceof Object&&typeof arguments[1]==="number"){args=[].slice.call(args,i)}else{args=[].slice.call(arguments,0)}}if(isArray(args)&&args.length===1&&typeof(args[0])==="function"){args=args[0]}if(typeof args==="function"){if(args.execute===CALLBACK.prototype.execute){return args}return CALLBACK({hook:args})}else{if(isArray(args)){if(typeof(args[0])==="string"&&args[1] instanceof Object&&typeof args[1][args[0]]==="function"){return CALLBACK({hook:args[1][args[0]],object:args[1],data:args.slice(2)})}else{if(typeof args[0]==="function"){return CALLBACK({hook:args[0],data:args.slice(1)})}else{if(typeof args[1]==="function"){return CALLBACK({hook:args[1],object:args[0],data:args.slice(2)})}}}}else{if(typeof(args)==="string"){if(TESTEVAL){TESTEVAL()}return CALLBACK({hook:EVAL,data:[args]})}else{if(args instanceof Object){return CALLBACK(args)}else{if(typeof(args)==="undefined"){return CALLBACK({})}}}}}throw Error("Can't make callback from given data")};var DELAY=function(time,callback){callback=USING(callback);callback.timeout=setTimeout(callback,time);return callback};var WAITFOR=function(callback,signal){callback=USING(callback);if(!callback.called){WAITSIGNAL(callback,signal);signal.pending++}};var WAITEXECUTE=function(){var signals=this.signal;delete this.signal;this.execute=this.oldExecute;delete this.oldExecute;var result=this.execute.apply(this,arguments);if(ISCALLBACK(result)&&!result.called){WAITSIGNAL(result,signals)}else{for(var i=0,m=signals.length;i0&&priority=0;i--){this.hooks.splice(i,1)}this.remove=[]}});var EXECUTEHOOKS=function(hooks,data,reset){if(!hooks){return null}if(!isArray(hooks)){hooks=[hooks]}if(!isArray(data)){data=(data==null?[]:[data])}var handler=HOOKS(reset);for(var i=0,m=hooks.length;ig){g=document.styleSheets.length}if(!i){i=document.head||((document.getElementsByTagName("head"))[0]);if(!i){i=document.body}}return i};var f=[];var c=function(){for(var k=0,j=f.length;k=this.timeout){i(this.STATUS.ERROR);return 1}return 0},file:function(j,i){if(i<0){a.Ajax.loadTimeout(j)}else{a.Ajax.loadComplete(j)}},execute:function(){this.hook.call(this.object,this,this.data[0],this.data[1])},checkSafari2:function(i,j,k){if(i.time(k)){return}if(document.styleSheets.length>j&&document.styleSheets[j].cssRules&&document.styleSheets[j].cssRules.length){k(i.STATUS.OK)}else{setTimeout(i,i.delay)}},checkLength:function(i,l,n){if(i.time(n)){return}var m=0;var j=(l.sheet||l.styleSheet);try{if((j.cssRules||j.rules||[]).length>0){m=1}}catch(k){if(k.message.match(/protected variable|restricted URI/)){m=1}else{if(k.message.match(/Security error/)){m=1}}}if(m){setTimeout(a.Callback([n,i.STATUS.OK]),0)}else{setTimeout(i,i.delay)}}},loadComplete:function(i){i=this.fileURL(i);var j=this.loading[i];if(j&&!j.preloaded){a.Message.Clear(j.message);clearTimeout(j.timeout);if(j.script){if(f.length===0){setTimeout(c,0)}f.push(j.script)}this.loaded[i]=j.status;delete this.loading[i];this.addHook(i,j.callback)}else{if(j){delete this.loading[i]}this.loaded[i]=this.STATUS.OK;j={status:this.STATUS.OK}}if(!this.loadHooks[i]){return null}return this.loadHooks[i].Execute(j.status)},loadTimeout:function(i){if(this.loading[i].timeout){clearTimeout(this.loading[i].timeout)}this.loading[i].status=this.STATUS.ERROR;this.loadError(i);this.loadComplete(i)},loadError:function(i){a.Message.Set(["LoadFailed","File failed to load: %1",i],null,2000);a.Hub.signal.Post(["file load error",i])},Styles:function(k,l){var i=this.StyleString(k);if(i===""){l=a.Callback(l);l()}else{var j=document.createElement("style");j.type="text/css";this.head=h(this.head);this.head.appendChild(j);if(j.styleSheet&&typeof(j.styleSheet.cssText)!=="undefined"){j.styleSheet.cssText=i}else{j.appendChild(document.createTextNode(i))}l=this.timer.create.call(this,l,j)}return l},StyleString:function(n){if(typeof(n)==="string"){return n}var k="",o,m;for(o in n){if(n.hasOwnProperty(o)){if(typeof n[o]==="string"){k+=o+" {"+n[o]+"}\n"}else{if(a.Object.isArray(n[o])){for(var l=0;l="0"&&q<="9"){f[j]=p[f[j]-1];if(typeof f[j]==="number"){f[j]=this.number(f[j])}}else{if(q==="{"){q=f[j].substr(1);if(q>="0"&&q<="9"){f[j]=p[f[j].substr(1,f[j].length-2)-1];if(typeof f[j]==="number"){f[j]=this.number(f[j])}}else{var k=f[j].match(/^\{([a-z]+):%(\d+)\|(.*)\}$/);if(k){if(k[1]==="plural"){var d=p[k[2]-1];if(typeof d==="undefined"){f[j]="???"}else{d=this.plural(d)-1;var h=k[3].replace(/(^|[^%])(%%)*%\|/g,"$1$2%\uEFEF").split(/\|/);if(d>=0&&d=3){c.push([f[0],f[1],this.processSnippet(g,f[2])])}else{c.push(e[d])}}}}else{c.push(e[d])}}return c},markdownPattern:/(%.)|(\*{1,3})((?:%.|.)+?)\2|(`+)((?:%.|.)+?)\4|\[((?:%.|.)+?)\]\(([^\s\)]+)\)/,processMarkdown:function(b,h,d){var j=[],e;var c=b.split(this.markdownPattern);var g=c[0];for(var f=1,a=c.length;f1?d[1]:""));f=null}if(e&&(!b.preJax||d)){c.nodeValue=c.nodeValue.replace(b.postJax,(e.length>1?e[1]:""))}if(f&&!f.nodeValue.match(/\S/)){f=f.previousSibling}}if(b.preRemoveClass&&f&&f.className===b.preRemoveClass){a.MathJax.preview=f}a.MathJax.checked=1},processInput:function(a){var b,i=MathJax.ElementJax.STATE;var h,e,d=a.scripts.length;try{while(a.ithis.processUpdateTime&&a.i1){d.jax[a.outputJax].push(b)}b.MathJax.state=c.OUTPUT},prepareOutput:function(c,f){while(c.jthis.processUpdateTime&&h.i=0;q--){if((b[q].src||"").match(f)){s.script=b[q].innerHTML;if(RegExp.$2){var t=RegExp.$2.substr(1).split(/\&/);for(var p=0,l=t.length;p=parseInt(y[z])}}return true},Select:function(j){var i=j[d.Browser];if(i){return i(d.Browser)}return null}};var e=k.replace(/^Mozilla\/(\d+\.)+\d+ /,"").replace(/[a-z][-a-z0-9._: ]+\/\d+[^ ]*-[^ ]*\.([a-z][a-z])?\d+ /i,"").replace(/Gentoo |Ubuntu\/(\d+\.)*\d+ (\([^)]*\) )?/,"");d.Browser=d.Insert(d.Insert(new String("Unknown"),{version:"0.0"}),a);for(var v in a){if(a.hasOwnProperty(v)){if(a[v]&&v.substr(0,2)==="is"){v=v.slice(2);if(v==="Mac"||v==="PC"){continue}d.Browser=d.Insert(new String(v),a);var r=new RegExp(".*(Version/| Trident/.*; rv:)((?:\\d+\\.)+\\d+)|.*("+v+")"+(v=="MSIE"?" ":"/")+"((?:\\d+\\.)*\\d+)|(?:^|\\(| )([a-z][-a-z0-9._: ]+|(?:Apple)?WebKit)/((?:\\d+\\.)+\\d+)");var u=r.exec(e)||["","","","unknown","0.0"];d.Browser.name=(u[1]!=""?v:(u[3]||u[5]));d.Browser.version=u[2]||u[4]||u[6];break}}}try{d.Browser.Select({Safari:function(j){var i=parseInt((String(j.version).split("."))[0]);if(i>85){j.webkit=j.version}if(i>=538){j.version="8.0"}else{if(i>=537){j.version="7.0"}else{if(i>=536){j.version="6.0"}else{if(i>=534){j.version="5.1"}else{if(i>=533){j.version="5.0"}else{if(i>=526){j.version="4.0"}else{if(i>=525){j.version="3.1"}else{if(i>500){j.version="3.0"}else{if(i>400){j.version="2.0"}else{if(i>85){j.version="1.0"}}}}}}}}}}j.webkit=(navigator.appVersion.match(/WebKit\/(\d+)\./))[1];j.isMobile=(navigator.appVersion.match(/Mobile/i)!=null);j.noContextMenu=j.isMobile},Firefox:function(j){if((j.version==="0.0"||k.match(/Firefox/)==null)&&navigator.product==="Gecko"){var m=k.match(/[\/ ]rv:(\d+\.\d.*?)[\) ]/);if(m){j.version=m[1]}else{var i=(navigator.buildID||navigator.productSub||"0").substr(0,8);if(i>="20111220"){j.version="9.0"}else{if(i>="20111120"){j.version="8.0"}else{if(i>="20110927"){j.version="7.0"}else{if(i>="20110816"){j.version="6.0"}else{if(i>="20110621"){j.version="5.0"}else{if(i>="20110320"){j.version="4.0"}else{if(i>="20100121"){j.version="3.6"}else{if(i>="20090630"){j.version="3.5"}else{if(i>="20080617"){j.version="3.0"}else{if(i>="20061024"){j.version="2.0"}}}}}}}}}}}}j.isMobile=(navigator.appVersion.match(/Android/i)!=null||k.match(/ Fennec\//)!=null||k.match(/Mobile/)!=null)},Chrome:function(i){i.noContextMenu=i.isMobile=!!navigator.userAgent.match(/ Mobile[ \/]/)},Opera:function(i){i.version=opera.version()},Edge:function(i){i.isMobile=!!navigator.userAgent.match(/ Phone/)},MSIE:function(j){j.isMobile=!!navigator.userAgent.match(/ Phone/);j.isIE9=!!(document.documentMode&&(window.performance||window.msPerformance));MathJax.HTML.setScriptBug=!j.isIE9||document.documentMode<9;MathJax.Hub.msieHTMLCollectionBug=(document.documentMode<9);if(document.documentMode<10&&!s.params.NoMathPlayer){try{new ActiveXObject("MathPlayer.Factory.1");j.hasMathPlayer=true}catch(m){}try{if(j.hasMathPlayer){var i=document.createElement("object");i.id="mathplayer";i.classid="clsid:32F66A20-7614-11D4-BD11-00104BD3F987";g.appendChild(i);document.namespaces.add("m","http://www.w3.org/1998/Math/MathML");j.mpNamespace=true;if(document.readyState&&(document.readyState==="loading"||document.readyState==="interactive")){document.write('');j.mpImported=true}}else{document.namespaces.add("mjx_IE_fix","http://www.w3.org/1999/xlink")}}catch(m){}}}})}catch(c){console.error(c.message)}d.Browser.Select(MathJax.Message.browsers);if(h.AuthorConfig&&typeof h.AuthorConfig.AuthorInit==="function"){h.AuthorConfig.AuthorInit()}d.queue=h.Callback.Queue();d.queue.Push(["Post",s.signal,"Begin"],["Config",s],["Cookie",s],["Styles",s],["Message",s],function(){var i=h.Callback.Queue(s.Jax(),s.Extensions());return i.Push({})},["Menu",s],s.onLoad(),function(){MathJax.isReady=true},["Typeset",s],["Hash",s],["MenuZoom",s],["Post",s.signal,"End"])})("MathJax")}}; diff --git a/src/lib/better_launch/docs/benchmarks/results/psutil/cpu_usage_files/maplibre-gl.css b/src/lib/better_launch/docs/benchmarks/results/psutil/cpu_usage_files/maplibre-gl.css new file mode 100644 index 0000000000..6ceb5de955 --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/results/psutil/cpu_usage_files/maplibre-gl.css @@ -0,0 +1 @@ +.maplibregl-map{font:12px/20px Helvetica Neue,Arial,Helvetica,sans-serif;overflow:hidden;position:relative;-webkit-tap-highlight-color:rgb(0 0 0/0)}.maplibregl-canvas{left:0;position:absolute;top:0}.maplibregl-map:fullscreen{height:100%;width:100%}.maplibregl-ctrl-group button.maplibregl-ctrl-compass{touch-action:none}.maplibregl-canvas-container.maplibregl-interactive,.maplibregl-ctrl-group button.maplibregl-ctrl-compass{cursor:grab;-webkit-user-select:none;-moz-user-select:none;user-select:none}.maplibregl-canvas-container.maplibregl-interactive.maplibregl-track-pointer{cursor:pointer}.maplibregl-canvas-container.maplibregl-interactive:active,.maplibregl-ctrl-group button.maplibregl-ctrl-compass:active{cursor:grabbing}.maplibregl-canvas-container.maplibregl-touch-zoom-rotate,.maplibregl-canvas-container.maplibregl-touch-zoom-rotate .maplibregl-canvas{touch-action:pan-x pan-y}.maplibregl-canvas-container.maplibregl-touch-drag-pan,.maplibregl-canvas-container.maplibregl-touch-drag-pan .maplibregl-canvas{touch-action:pinch-zoom}.maplibregl-canvas-container.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan,.maplibregl-canvas-container.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan .maplibregl-canvas{touch-action:none}.maplibregl-canvas-container.maplibregl-touch-drag-pan.maplibregl-cooperative-gestures,.maplibregl-canvas-container.maplibregl-touch-drag-pan.maplibregl-cooperative-gestures .maplibregl-canvas{touch-action:pan-x pan-y}.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right,.maplibregl-ctrl-top-left,.maplibregl-ctrl-top-right{pointer-events:none;position:absolute;z-index:2}.maplibregl-ctrl-top-left{left:0;top:0}.maplibregl-ctrl-top-right{right:0;top:0}.maplibregl-ctrl-bottom-left{bottom:0;left:0}.maplibregl-ctrl-bottom-right{bottom:0;right:0}.maplibregl-ctrl{clear:both;pointer-events:auto;transform:translate(0)}.maplibregl-ctrl-top-left .maplibregl-ctrl{float:left;margin:10px 0 0 10px}.maplibregl-ctrl-top-right .maplibregl-ctrl{float:right;margin:10px 10px 0 0}.maplibregl-ctrl-bottom-left .maplibregl-ctrl{float:left;margin:0 0 10px 10px}.maplibregl-ctrl-bottom-right .maplibregl-ctrl{float:right;margin:0 10px 10px 0}.maplibregl-ctrl-group{background:#fff;border-radius:4px}.maplibregl-ctrl-group:not(:empty){box-shadow:0 0 0 2px rgba(0,0,0,.1)}@media (forced-colors:active){.maplibregl-ctrl-group:not(:empty){box-shadow:0 0 0 2px ButtonText}}.maplibregl-ctrl-group button{background-color:transparent;border:0;box-sizing:border-box;cursor:pointer;display:block;height:29px;outline:none;padding:0;width:29px}.maplibregl-ctrl-group button+button{border-top:1px solid #ddd}.maplibregl-ctrl button .maplibregl-ctrl-icon{background-position:50%;background-repeat:no-repeat;display:block;height:100%;width:100%}@media (forced-colors:active){.maplibregl-ctrl-icon{background-color:transparent}.maplibregl-ctrl-group button+button{border-top:1px solid ButtonText}}.maplibregl-ctrl button::-moz-focus-inner{border:0;padding:0}.maplibregl-ctrl-attrib-button:focus,.maplibregl-ctrl-group button:focus{box-shadow:0 0 2px 2px #0096ff}.maplibregl-ctrl button:disabled{cursor:not-allowed}.maplibregl-ctrl button:disabled .maplibregl-ctrl-icon{opacity:.25}.maplibregl-ctrl button:not(:disabled):hover{background-color:rgb(0 0 0/5%)}.maplibregl-ctrl-group button:focus:focus-visible{box-shadow:0 0 2px 2px #0096ff}.maplibregl-ctrl-group button:focus:not(:focus-visible){box-shadow:none}.maplibregl-ctrl-group button:focus:first-child{border-radius:4px 4px 0 0}.maplibregl-ctrl-group button:focus:last-child{border-radius:0 0 4px 4px}.maplibregl-ctrl-group button:focus:only-child{border-radius:inherit}.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-terrain .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='%23333' viewBox='0 0 22 22'%3E%3Cpath d='m1.754 13.406 4.453-4.851 3.09 3.09 3.281 3.277.969-.969-3.309-3.312 3.844-4.121 6.148 6.886h1.082v-.855l-7.207-8.07-4.84 5.187L6.169 6.57l-5.48 5.965v.871ZM.688 16.844h20.625v1.375H.688Zm0 0'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-terrain-enabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='%2333b5e5' viewBox='0 0 22 22'%3E%3Cpath d='m1.754 13.406 4.453-4.851 3.09 3.09 3.281 3.277.969-.969-3.309-3.312 3.844-4.121 6.148 6.886h1.082v-.855l-7.207-8.07-4.84 5.187L6.169 6.57l-5.48 5.965v.871ZM.688 16.844h20.625v1.375H.688Zm0 0'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23aaa' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e58978' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e54e33' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-waiting .maplibregl-ctrl-icon{animation:maplibregl-spin 2s linear infinite}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23999' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e58978' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e54e33' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23666' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}}@keyframes maplibregl-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}a.maplibregl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E");background-repeat:no-repeat;cursor:pointer;display:block;height:23px;margin:0 0 -4px -4px;overflow:hidden;width:88px}a.maplibregl-ctrl-logo.maplibregl-compact{width:14px}@media (forced-colors:active){a.maplibregl-ctrl-logo{background-color:transparent;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){a.maplibregl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E")}}.maplibregl-ctrl.maplibregl-ctrl-attrib{background-color:hsla(0,0%,100%,.5);margin:0;padding:0 5px}@media screen{.maplibregl-ctrl-attrib.maplibregl-compact{background-color:#fff;border-radius:12px;box-sizing:content-box;color:#000;margin:10px;min-height:20px;padding:2px 24px 2px 0;position:relative}.maplibregl-ctrl-attrib.maplibregl-compact-show{padding:2px 28px 2px 8px;visibility:visible}.maplibregl-ctrl-bottom-left>.maplibregl-ctrl-attrib.maplibregl-compact-show,.maplibregl-ctrl-top-left>.maplibregl-ctrl-attrib.maplibregl-compact-show{border-radius:12px;padding:2px 8px 2px 28px}.maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-inner{display:none}.maplibregl-ctrl-attrib-button{background-color:hsla(0,0%,100%,.5);background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E");border:0;border-radius:12px;box-sizing:border-box;cursor:pointer;display:none;height:24px;outline:none;position:absolute;right:0;top:0;width:24px}.maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button{-webkit-appearance:none;-moz-appearance:none;appearance:none;list-style:none}.maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button::-webkit-details-marker{display:none}.maplibregl-ctrl-bottom-left .maplibregl-ctrl-attrib-button,.maplibregl-ctrl-top-left .maplibregl-ctrl-attrib-button{left:0}.maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-button,.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-inner{display:block}.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-button{background-color:rgb(0 0 0/5%)}.maplibregl-ctrl-bottom-right>.maplibregl-ctrl-attrib.maplibregl-compact:after{bottom:0;right:0}.maplibregl-ctrl-top-right>.maplibregl-ctrl-attrib.maplibregl-compact:after{right:0;top:0}.maplibregl-ctrl-top-left>.maplibregl-ctrl-attrib.maplibregl-compact:after{left:0;top:0}.maplibregl-ctrl-bottom-left>.maplibregl-ctrl-attrib.maplibregl-compact:after{bottom:0;left:0}}@media screen and (forced-colors:active){.maplibregl-ctrl-attrib.maplibregl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='%23fff' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}@media screen and (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl-attrib.maplibregl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}.maplibregl-ctrl-attrib a{color:rgba(0,0,0,.75);text-decoration:none}.maplibregl-ctrl-attrib a:hover{color:inherit;text-decoration:underline}.maplibregl-attrib-empty{display:none}.maplibregl-ctrl-scale{background-color:hsla(0,0%,100%,.75);border:2px solid #333;border-top:#333;box-sizing:border-box;color:#333;font-size:10px;padding:0 5px}.maplibregl-popup{display:flex;left:0;pointer-events:none;position:absolute;top:0;will-change:transform}.maplibregl-popup-anchor-top,.maplibregl-popup-anchor-top-left,.maplibregl-popup-anchor-top-right{flex-direction:column}.maplibregl-popup-anchor-bottom,.maplibregl-popup-anchor-bottom-left,.maplibregl-popup-anchor-bottom-right{flex-direction:column-reverse}.maplibregl-popup-anchor-left{flex-direction:row}.maplibregl-popup-anchor-right{flex-direction:row-reverse}.maplibregl-popup-tip{border:10px solid transparent;height:0;width:0;z-index:1}.maplibregl-popup-anchor-top .maplibregl-popup-tip{align-self:center;border-bottom-color:#fff;border-top:none}.maplibregl-popup-anchor-top-left .maplibregl-popup-tip{align-self:flex-start;border-bottom-color:#fff;border-left:none;border-top:none}.maplibregl-popup-anchor-top-right .maplibregl-popup-tip{align-self:flex-end;border-bottom-color:#fff;border-right:none;border-top:none}.maplibregl-popup-anchor-bottom .maplibregl-popup-tip{align-self:center;border-bottom:none;border-top-color:#fff}.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip{align-self:flex-start;border-bottom:none;border-left:none;border-top-color:#fff}.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip{align-self:flex-end;border-bottom:none;border-right:none;border-top-color:#fff}.maplibregl-popup-anchor-left .maplibregl-popup-tip{align-self:center;border-left:none;border-right-color:#fff}.maplibregl-popup-anchor-right .maplibregl-popup-tip{align-self:center;border-left-color:#fff;border-right:none}.maplibregl-popup-close-button{background-color:transparent;border:0;border-radius:0 3px 0 0;cursor:pointer;position:absolute;right:0;top:0}.maplibregl-popup-close-button:hover{background-color:rgb(0 0 0/5%)}.maplibregl-popup-content{background:#fff;border-radius:3px;box-shadow:0 1px 2px rgba(0,0,0,.1);padding:15px 10px;pointer-events:auto;position:relative}.maplibregl-popup-anchor-top-left .maplibregl-popup-content{border-top-left-radius:0}.maplibregl-popup-anchor-top-right .maplibregl-popup-content{border-top-right-radius:0}.maplibregl-popup-anchor-bottom-left .maplibregl-popup-content{border-bottom-left-radius:0}.maplibregl-popup-anchor-bottom-right .maplibregl-popup-content{border-bottom-right-radius:0}.maplibregl-popup-track-pointer{display:none}.maplibregl-popup-track-pointer *{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.maplibregl-map:hover .maplibregl-popup-track-pointer{display:flex}.maplibregl-map:active .maplibregl-popup-track-pointer{display:none}.maplibregl-marker{left:0;position:absolute;top:0;transition:opacity .2s;will-change:transform}.maplibregl-user-location-dot,.maplibregl-user-location-dot:before{background-color:#1da1f2;border-radius:50%;height:15px;width:15px}.maplibregl-user-location-dot:before{animation:maplibregl-user-location-dot-pulse 2s infinite;content:"";position:absolute}.maplibregl-user-location-dot:after{border:2px solid #fff;border-radius:50%;box-shadow:0 0 3px rgba(0,0,0,.35);box-sizing:border-box;content:"";height:19px;left:-2px;position:absolute;top:-2px;width:19px}@keyframes maplibregl-user-location-dot-pulse{0%{opacity:1;transform:scale(1)}70%{opacity:0;transform:scale(3)}to{opacity:0;transform:scale(1)}}.maplibregl-user-location-dot-stale{background-color:#aaa}.maplibregl-user-location-dot-stale:after{display:none}.maplibregl-user-location-accuracy-circle{background-color:#1da1f233;border-radius:100%;height:1px;width:1px}.maplibregl-crosshair,.maplibregl-crosshair .maplibregl-interactive,.maplibregl-crosshair .maplibregl-interactive:active{cursor:crosshair}.maplibregl-boxzoom{background:#fff;border:2px dotted #202020;height:0;left:0;opacity:.5;position:absolute;top:0;width:0}.maplibregl-cooperative-gesture-screen{align-items:center;background:rgba(0,0,0,.4);color:#fff;display:flex;font-size:1.4em;inset:0;justify-content:center;line-height:1.2;opacity:0;padding:1rem;pointer-events:none;position:absolute;transition:opacity 1s ease 1s;z-index:99999}.maplibregl-cooperative-gesture-screen.maplibregl-show{opacity:1;transition:opacity .05s}.maplibregl-cooperative-gesture-screen .maplibregl-mobile-message{display:none}@media (hover:none),(width <= 480px){.maplibregl-cooperative-gesture-screen .maplibregl-desktop-message{display:none}.maplibregl-cooperative-gesture-screen .maplibregl-mobile-message{display:block}}.maplibregl-pseudo-fullscreen{height:100%!important;left:0!important;position:fixed!important;top:0!important;width:100%!important;z-index:99999} \ No newline at end of file diff --git a/src/lib/better_launch/docs/benchmarks/results/psutil/memory_usage.html b/src/lib/better_launch/docs/benchmarks/results/psutil/memory_usage.html new file mode 100644 index 0000000000..03a4625832 --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/results/psutil/memory_usage.html @@ -0,0 +1,125 @@ + + + +
+
0.511.522.533.544.520253035404550
better_launchros2Memory Usage Comparisontime_smemory_mb
+ + \ No newline at end of file diff --git a/src/lib/better_launch/docs/benchmarks/results/psutil/memory_usage.png b/src/lib/better_launch/docs/benchmarks/results/psutil/memory_usage.png new file mode 100644 index 0000000000..6bab5c24d5 Binary files /dev/null and b/src/lib/better_launch/docs/benchmarks/results/psutil/memory_usage.png differ diff --git a/src/lib/better_launch/docs/benchmarks/results/psutil/memory_usage_files/MathJax.js b/src/lib/better_launch/docs/benchmarks/results/psutil/memory_usage_files/MathJax.js new file mode 100644 index 0000000000..c54a1ed2d3 --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/results/psutil/memory_usage_files/MathJax.js @@ -0,0 +1,19 @@ +/* + * /MathJax.js + * + * Copyright (c) 2009-2018 The MathJax Consortium + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +if(document.getElementById&&document.childNodes&&document.createElement){if(!(window.MathJax&&MathJax.Hub)){if(window.MathJax){window.MathJax={AuthorConfig:window.MathJax}}else{window.MathJax={}}MathJax.isPacked=true;MathJax.version="2.7.5";MathJax.fileversion="2.7.5";MathJax.cdnVersion="2.7.5";MathJax.cdnFileVersions={};(function(d){var b=window[d];if(!b){b=window[d]={}}var e=[];var c=function(f){var g=f.constructor;if(!g){g=function(){}}for(var h in f){if(h!=="constructor"&&f.hasOwnProperty(h)){g[h]=f[h]}}return g};var a=function(){return function(){return arguments.callee.Init.call(this,arguments)}};b.Object=c({constructor:a(),Subclass:function(f,h){var g=a();g.SUPER=this;g.Init=this.Init;g.Subclass=this.Subclass;g.Augment=this.Augment;g.protoFunction=this.protoFunction;g.can=this.can;g.has=this.has;g.isa=this.isa;g.prototype=new this(e);g.prototype.constructor=g;g.Augment(f,h);return g},Init:function(f){var g=this;if(f.length===1&&f[0]===e){return g}if(!(g instanceof f.callee)){g=new f.callee(e)}return g.Init.apply(g,f)||g},Augment:function(f,g){var h;if(f!=null){for(h in f){if(f.hasOwnProperty(h)){this.protoFunction(h,f[h])}}if(f.toString!==this.prototype.toString&&f.toString!=={}.toString){this.protoFunction("toString",f.toString)}}if(g!=null){for(h in g){if(g.hasOwnProperty(h)){this[h]=g[h]}}}return this},protoFunction:function(g,f){this.prototype[g]=f;if(typeof f==="function"){f.SUPER=this.SUPER.prototype}},prototype:{Init:function(){},SUPER:function(f){return f.callee.SUPER},can:function(f){return typeof(this[f])==="function"},has:function(f){return typeof(this[f])!=="undefined"},isa:function(f){return(f instanceof Object)&&(this instanceof f)}},can:function(f){return this.prototype.can.call(this,f)},has:function(f){return this.prototype.has.call(this,f)},isa:function(g){var f=this;while(f){if(f===g){return true}else{f=f.SUPER}}return false},SimpleSUPER:c({constructor:function(f){return this.SimpleSUPER.define(f)},define:function(f){var h={};if(f!=null){for(var g in f){if(f.hasOwnProperty(g)){h[g]=this.wrap(g,f[g])}}if(f.toString!==this.prototype.toString&&f.toString!=={}.toString){h.toString=this.wrap("toString",f.toString)}}return h},wrap:function(i,h){if(typeof(h)!=="function"||!h.toString().match(/\.\s*SUPER\s*\(/)){return h}var g=function(){this.SUPER=g.SUPER[i];try{var f=h.apply(this,arguments)}catch(j){delete this.SUPER;throw j}delete this.SUPER;return f};g.toString=function(){return h.toString.apply(h,arguments)};return g}})});b.Object.isArray=Array.isArray||function(f){return Object.prototype.toString.call(f)==="[object Array]"};b.Object.Array=Array})("MathJax");(function(BASENAME){var BASE=window[BASENAME];if(!BASE){BASE=window[BASENAME]={}}var isArray=BASE.Object.isArray;var CALLBACK=function(data){var cb=function(){return arguments.callee.execute.apply(arguments.callee,arguments)};for(var id in CALLBACK.prototype){if(CALLBACK.prototype.hasOwnProperty(id)){if(typeof(data[id])!=="undefined"){cb[id]=data[id]}else{cb[id]=CALLBACK.prototype[id]}}}cb.toString=CALLBACK.prototype.toString;return cb};CALLBACK.prototype={isCallback:true,hook:function(){},data:[],object:window,execute:function(){if(!this.called||this.autoReset){this.called=!this.autoReset;return this.hook.apply(this.object,this.data.concat([].slice.call(arguments,0)))}},reset:function(){delete this.called},toString:function(){return this.hook.toString.apply(this.hook,arguments)}};var ISCALLBACK=function(f){return(typeof(f)==="function"&&f.isCallback)};var EVAL=function(code){return eval.call(window,code)};var TESTEVAL=function(){EVAL("var __TeSt_VaR__ = 1");if(window.__TeSt_VaR__){try{delete window.__TeSt_VaR__}catch(error){window.__TeSt_VaR__=null}}else{if(window.execScript){EVAL=function(code){BASE.__code=code;code="try {"+BASENAME+".__result = eval("+BASENAME+".__code)} catch(err) {"+BASENAME+".__result = err}";window.execScript(code);var result=BASE.__result;delete BASE.__result;delete BASE.__code;if(result instanceof Error){throw result}return result}}else{EVAL=function(code){BASE.__code=code;code="try {"+BASENAME+".__result = eval("+BASENAME+".__code)} catch(err) {"+BASENAME+".__result = err}";var head=(document.getElementsByTagName("head"))[0];if(!head){head=document.body}var script=document.createElement("script");script.appendChild(document.createTextNode(code));head.appendChild(script);head.removeChild(script);var result=BASE.__result;delete BASE.__result;delete BASE.__code;if(result instanceof Error){throw result}return result}}}TESTEVAL=null};var USING=function(args,i){if(arguments.length>1){if(arguments.length===2&&!(typeof arguments[0]==="function")&&arguments[0] instanceof Object&&typeof arguments[1]==="number"){args=[].slice.call(args,i)}else{args=[].slice.call(arguments,0)}}if(isArray(args)&&args.length===1&&typeof(args[0])==="function"){args=args[0]}if(typeof args==="function"){if(args.execute===CALLBACK.prototype.execute){return args}return CALLBACK({hook:args})}else{if(isArray(args)){if(typeof(args[0])==="string"&&args[1] instanceof Object&&typeof args[1][args[0]]==="function"){return CALLBACK({hook:args[1][args[0]],object:args[1],data:args.slice(2)})}else{if(typeof args[0]==="function"){return CALLBACK({hook:args[0],data:args.slice(1)})}else{if(typeof args[1]==="function"){return CALLBACK({hook:args[1],object:args[0],data:args.slice(2)})}}}}else{if(typeof(args)==="string"){if(TESTEVAL){TESTEVAL()}return CALLBACK({hook:EVAL,data:[args]})}else{if(args instanceof Object){return CALLBACK(args)}else{if(typeof(args)==="undefined"){return CALLBACK({})}}}}}throw Error("Can't make callback from given data")};var DELAY=function(time,callback){callback=USING(callback);callback.timeout=setTimeout(callback,time);return callback};var WAITFOR=function(callback,signal){callback=USING(callback);if(!callback.called){WAITSIGNAL(callback,signal);signal.pending++}};var WAITEXECUTE=function(){var signals=this.signal;delete this.signal;this.execute=this.oldExecute;delete this.oldExecute;var result=this.execute.apply(this,arguments);if(ISCALLBACK(result)&&!result.called){WAITSIGNAL(result,signals)}else{for(var i=0,m=signals.length;i0&&priority=0;i--){this.hooks.splice(i,1)}this.remove=[]}});var EXECUTEHOOKS=function(hooks,data,reset){if(!hooks){return null}if(!isArray(hooks)){hooks=[hooks]}if(!isArray(data)){data=(data==null?[]:[data])}var handler=HOOKS(reset);for(var i=0,m=hooks.length;ig){g=document.styleSheets.length}if(!i){i=document.head||((document.getElementsByTagName("head"))[0]);if(!i){i=document.body}}return i};var f=[];var c=function(){for(var k=0,j=f.length;k=this.timeout){i(this.STATUS.ERROR);return 1}return 0},file:function(j,i){if(i<0){a.Ajax.loadTimeout(j)}else{a.Ajax.loadComplete(j)}},execute:function(){this.hook.call(this.object,this,this.data[0],this.data[1])},checkSafari2:function(i,j,k){if(i.time(k)){return}if(document.styleSheets.length>j&&document.styleSheets[j].cssRules&&document.styleSheets[j].cssRules.length){k(i.STATUS.OK)}else{setTimeout(i,i.delay)}},checkLength:function(i,l,n){if(i.time(n)){return}var m=0;var j=(l.sheet||l.styleSheet);try{if((j.cssRules||j.rules||[]).length>0){m=1}}catch(k){if(k.message.match(/protected variable|restricted URI/)){m=1}else{if(k.message.match(/Security error/)){m=1}}}if(m){setTimeout(a.Callback([n,i.STATUS.OK]),0)}else{setTimeout(i,i.delay)}}},loadComplete:function(i){i=this.fileURL(i);var j=this.loading[i];if(j&&!j.preloaded){a.Message.Clear(j.message);clearTimeout(j.timeout);if(j.script){if(f.length===0){setTimeout(c,0)}f.push(j.script)}this.loaded[i]=j.status;delete this.loading[i];this.addHook(i,j.callback)}else{if(j){delete this.loading[i]}this.loaded[i]=this.STATUS.OK;j={status:this.STATUS.OK}}if(!this.loadHooks[i]){return null}return this.loadHooks[i].Execute(j.status)},loadTimeout:function(i){if(this.loading[i].timeout){clearTimeout(this.loading[i].timeout)}this.loading[i].status=this.STATUS.ERROR;this.loadError(i);this.loadComplete(i)},loadError:function(i){a.Message.Set(["LoadFailed","File failed to load: %1",i],null,2000);a.Hub.signal.Post(["file load error",i])},Styles:function(k,l){var i=this.StyleString(k);if(i===""){l=a.Callback(l);l()}else{var j=document.createElement("style");j.type="text/css";this.head=h(this.head);this.head.appendChild(j);if(j.styleSheet&&typeof(j.styleSheet.cssText)!=="undefined"){j.styleSheet.cssText=i}else{j.appendChild(document.createTextNode(i))}l=this.timer.create.call(this,l,j)}return l},StyleString:function(n){if(typeof(n)==="string"){return n}var k="",o,m;for(o in n){if(n.hasOwnProperty(o)){if(typeof n[o]==="string"){k+=o+" {"+n[o]+"}\n"}else{if(a.Object.isArray(n[o])){for(var l=0;l="0"&&q<="9"){f[j]=p[f[j]-1];if(typeof f[j]==="number"){f[j]=this.number(f[j])}}else{if(q==="{"){q=f[j].substr(1);if(q>="0"&&q<="9"){f[j]=p[f[j].substr(1,f[j].length-2)-1];if(typeof f[j]==="number"){f[j]=this.number(f[j])}}else{var k=f[j].match(/^\{([a-z]+):%(\d+)\|(.*)\}$/);if(k){if(k[1]==="plural"){var d=p[k[2]-1];if(typeof d==="undefined"){f[j]="???"}else{d=this.plural(d)-1;var h=k[3].replace(/(^|[^%])(%%)*%\|/g,"$1$2%\uEFEF").split(/\|/);if(d>=0&&d=3){c.push([f[0],f[1],this.processSnippet(g,f[2])])}else{c.push(e[d])}}}}else{c.push(e[d])}}return c},markdownPattern:/(%.)|(\*{1,3})((?:%.|.)+?)\2|(`+)((?:%.|.)+?)\4|\[((?:%.|.)+?)\]\(([^\s\)]+)\)/,processMarkdown:function(b,h,d){var j=[],e;var c=b.split(this.markdownPattern);var g=c[0];for(var f=1,a=c.length;f1?d[1]:""));f=null}if(e&&(!b.preJax||d)){c.nodeValue=c.nodeValue.replace(b.postJax,(e.length>1?e[1]:""))}if(f&&!f.nodeValue.match(/\S/)){f=f.previousSibling}}if(b.preRemoveClass&&f&&f.className===b.preRemoveClass){a.MathJax.preview=f}a.MathJax.checked=1},processInput:function(a){var b,i=MathJax.ElementJax.STATE;var h,e,d=a.scripts.length;try{while(a.ithis.processUpdateTime&&a.i1){d.jax[a.outputJax].push(b)}b.MathJax.state=c.OUTPUT},prepareOutput:function(c,f){while(c.jthis.processUpdateTime&&h.i=0;q--){if((b[q].src||"").match(f)){s.script=b[q].innerHTML;if(RegExp.$2){var t=RegExp.$2.substr(1).split(/\&/);for(var p=0,l=t.length;p=parseInt(y[z])}}return true},Select:function(j){var i=j[d.Browser];if(i){return i(d.Browser)}return null}};var e=k.replace(/^Mozilla\/(\d+\.)+\d+ /,"").replace(/[a-z][-a-z0-9._: ]+\/\d+[^ ]*-[^ ]*\.([a-z][a-z])?\d+ /i,"").replace(/Gentoo |Ubuntu\/(\d+\.)*\d+ (\([^)]*\) )?/,"");d.Browser=d.Insert(d.Insert(new String("Unknown"),{version:"0.0"}),a);for(var v in a){if(a.hasOwnProperty(v)){if(a[v]&&v.substr(0,2)==="is"){v=v.slice(2);if(v==="Mac"||v==="PC"){continue}d.Browser=d.Insert(new String(v),a);var r=new RegExp(".*(Version/| Trident/.*; rv:)((?:\\d+\\.)+\\d+)|.*("+v+")"+(v=="MSIE"?" ":"/")+"((?:\\d+\\.)*\\d+)|(?:^|\\(| )([a-z][-a-z0-9._: ]+|(?:Apple)?WebKit)/((?:\\d+\\.)+\\d+)");var u=r.exec(e)||["","","","unknown","0.0"];d.Browser.name=(u[1]!=""?v:(u[3]||u[5]));d.Browser.version=u[2]||u[4]||u[6];break}}}try{d.Browser.Select({Safari:function(j){var i=parseInt((String(j.version).split("."))[0]);if(i>85){j.webkit=j.version}if(i>=538){j.version="8.0"}else{if(i>=537){j.version="7.0"}else{if(i>=536){j.version="6.0"}else{if(i>=534){j.version="5.1"}else{if(i>=533){j.version="5.0"}else{if(i>=526){j.version="4.0"}else{if(i>=525){j.version="3.1"}else{if(i>500){j.version="3.0"}else{if(i>400){j.version="2.0"}else{if(i>85){j.version="1.0"}}}}}}}}}}j.webkit=(navigator.appVersion.match(/WebKit\/(\d+)\./))[1];j.isMobile=(navigator.appVersion.match(/Mobile/i)!=null);j.noContextMenu=j.isMobile},Firefox:function(j){if((j.version==="0.0"||k.match(/Firefox/)==null)&&navigator.product==="Gecko"){var m=k.match(/[\/ ]rv:(\d+\.\d.*?)[\) ]/);if(m){j.version=m[1]}else{var i=(navigator.buildID||navigator.productSub||"0").substr(0,8);if(i>="20111220"){j.version="9.0"}else{if(i>="20111120"){j.version="8.0"}else{if(i>="20110927"){j.version="7.0"}else{if(i>="20110816"){j.version="6.0"}else{if(i>="20110621"){j.version="5.0"}else{if(i>="20110320"){j.version="4.0"}else{if(i>="20100121"){j.version="3.6"}else{if(i>="20090630"){j.version="3.5"}else{if(i>="20080617"){j.version="3.0"}else{if(i>="20061024"){j.version="2.0"}}}}}}}}}}}}j.isMobile=(navigator.appVersion.match(/Android/i)!=null||k.match(/ Fennec\//)!=null||k.match(/Mobile/)!=null)},Chrome:function(i){i.noContextMenu=i.isMobile=!!navigator.userAgent.match(/ Mobile[ \/]/)},Opera:function(i){i.version=opera.version()},Edge:function(i){i.isMobile=!!navigator.userAgent.match(/ Phone/)},MSIE:function(j){j.isMobile=!!navigator.userAgent.match(/ Phone/);j.isIE9=!!(document.documentMode&&(window.performance||window.msPerformance));MathJax.HTML.setScriptBug=!j.isIE9||document.documentMode<9;MathJax.Hub.msieHTMLCollectionBug=(document.documentMode<9);if(document.documentMode<10&&!s.params.NoMathPlayer){try{new ActiveXObject("MathPlayer.Factory.1");j.hasMathPlayer=true}catch(m){}try{if(j.hasMathPlayer){var i=document.createElement("object");i.id="mathplayer";i.classid="clsid:32F66A20-7614-11D4-BD11-00104BD3F987";g.appendChild(i);document.namespaces.add("m","http://www.w3.org/1998/Math/MathML");j.mpNamespace=true;if(document.readyState&&(document.readyState==="loading"||document.readyState==="interactive")){document.write('');j.mpImported=true}}else{document.namespaces.add("mjx_IE_fix","http://www.w3.org/1999/xlink")}}catch(m){}}}})}catch(c){console.error(c.message)}d.Browser.Select(MathJax.Message.browsers);if(h.AuthorConfig&&typeof h.AuthorConfig.AuthorInit==="function"){h.AuthorConfig.AuthorInit()}d.queue=h.Callback.Queue();d.queue.Push(["Post",s.signal,"Begin"],["Config",s],["Cookie",s],["Styles",s],["Message",s],function(){var i=h.Callback.Queue(s.Jax(),s.Extensions());return i.Push({})},["Menu",s],s.onLoad(),function(){MathJax.isReady=true},["Typeset",s],["Hash",s],["MenuZoom",s],["Post",s.signal,"End"])})("MathJax")}}; diff --git a/src/lib/better_launch/docs/benchmarks/results/psutil/memory_usage_files/maplibre-gl.css b/src/lib/better_launch/docs/benchmarks/results/psutil/memory_usage_files/maplibre-gl.css new file mode 100644 index 0000000000..6ceb5de955 --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/results/psutil/memory_usage_files/maplibre-gl.css @@ -0,0 +1 @@ +.maplibregl-map{font:12px/20px Helvetica Neue,Arial,Helvetica,sans-serif;overflow:hidden;position:relative;-webkit-tap-highlight-color:rgb(0 0 0/0)}.maplibregl-canvas{left:0;position:absolute;top:0}.maplibregl-map:fullscreen{height:100%;width:100%}.maplibregl-ctrl-group button.maplibregl-ctrl-compass{touch-action:none}.maplibregl-canvas-container.maplibregl-interactive,.maplibregl-ctrl-group button.maplibregl-ctrl-compass{cursor:grab;-webkit-user-select:none;-moz-user-select:none;user-select:none}.maplibregl-canvas-container.maplibregl-interactive.maplibregl-track-pointer{cursor:pointer}.maplibregl-canvas-container.maplibregl-interactive:active,.maplibregl-ctrl-group button.maplibregl-ctrl-compass:active{cursor:grabbing}.maplibregl-canvas-container.maplibregl-touch-zoom-rotate,.maplibregl-canvas-container.maplibregl-touch-zoom-rotate .maplibregl-canvas{touch-action:pan-x pan-y}.maplibregl-canvas-container.maplibregl-touch-drag-pan,.maplibregl-canvas-container.maplibregl-touch-drag-pan .maplibregl-canvas{touch-action:pinch-zoom}.maplibregl-canvas-container.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan,.maplibregl-canvas-container.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan .maplibregl-canvas{touch-action:none}.maplibregl-canvas-container.maplibregl-touch-drag-pan.maplibregl-cooperative-gestures,.maplibregl-canvas-container.maplibregl-touch-drag-pan.maplibregl-cooperative-gestures .maplibregl-canvas{touch-action:pan-x pan-y}.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right,.maplibregl-ctrl-top-left,.maplibregl-ctrl-top-right{pointer-events:none;position:absolute;z-index:2}.maplibregl-ctrl-top-left{left:0;top:0}.maplibregl-ctrl-top-right{right:0;top:0}.maplibregl-ctrl-bottom-left{bottom:0;left:0}.maplibregl-ctrl-bottom-right{bottom:0;right:0}.maplibregl-ctrl{clear:both;pointer-events:auto;transform:translate(0)}.maplibregl-ctrl-top-left .maplibregl-ctrl{float:left;margin:10px 0 0 10px}.maplibregl-ctrl-top-right .maplibregl-ctrl{float:right;margin:10px 10px 0 0}.maplibregl-ctrl-bottom-left .maplibregl-ctrl{float:left;margin:0 0 10px 10px}.maplibregl-ctrl-bottom-right .maplibregl-ctrl{float:right;margin:0 10px 10px 0}.maplibregl-ctrl-group{background:#fff;border-radius:4px}.maplibregl-ctrl-group:not(:empty){box-shadow:0 0 0 2px rgba(0,0,0,.1)}@media (forced-colors:active){.maplibregl-ctrl-group:not(:empty){box-shadow:0 0 0 2px ButtonText}}.maplibregl-ctrl-group button{background-color:transparent;border:0;box-sizing:border-box;cursor:pointer;display:block;height:29px;outline:none;padding:0;width:29px}.maplibregl-ctrl-group button+button{border-top:1px solid #ddd}.maplibregl-ctrl button .maplibregl-ctrl-icon{background-position:50%;background-repeat:no-repeat;display:block;height:100%;width:100%}@media (forced-colors:active){.maplibregl-ctrl-icon{background-color:transparent}.maplibregl-ctrl-group button+button{border-top:1px solid ButtonText}}.maplibregl-ctrl button::-moz-focus-inner{border:0;padding:0}.maplibregl-ctrl-attrib-button:focus,.maplibregl-ctrl-group button:focus{box-shadow:0 0 2px 2px #0096ff}.maplibregl-ctrl button:disabled{cursor:not-allowed}.maplibregl-ctrl button:disabled .maplibregl-ctrl-icon{opacity:.25}.maplibregl-ctrl button:not(:disabled):hover{background-color:rgb(0 0 0/5%)}.maplibregl-ctrl-group button:focus:focus-visible{box-shadow:0 0 2px 2px #0096ff}.maplibregl-ctrl-group button:focus:not(:focus-visible){box-shadow:none}.maplibregl-ctrl-group button:focus:first-child{border-radius:4px 4px 0 0}.maplibregl-ctrl-group button:focus:last-child{border-radius:0 0 4px 4px}.maplibregl-ctrl-group button:focus:only-child{border-radius:inherit}.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-terrain .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='%23333' viewBox='0 0 22 22'%3E%3Cpath d='m1.754 13.406 4.453-4.851 3.09 3.09 3.281 3.277.969-.969-3.309-3.312 3.844-4.121 6.148 6.886h1.082v-.855l-7.207-8.07-4.84 5.187L6.169 6.57l-5.48 5.965v.871ZM.688 16.844h20.625v1.375H.688Zm0 0'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-terrain-enabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='%2333b5e5' viewBox='0 0 22 22'%3E%3Cpath d='m1.754 13.406 4.453-4.851 3.09 3.09 3.281 3.277.969-.969-3.309-3.312 3.844-4.121 6.148 6.886h1.082v-.855l-7.207-8.07-4.84 5.187L6.169 6.57l-5.48 5.965v.871ZM.688 16.844h20.625v1.375H.688Zm0 0'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23aaa' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e58978' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e54e33' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-waiting .maplibregl-ctrl-icon{animation:maplibregl-spin 2s linear infinite}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23999' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e58978' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e54e33' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23666' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}}@keyframes maplibregl-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}a.maplibregl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E");background-repeat:no-repeat;cursor:pointer;display:block;height:23px;margin:0 0 -4px -4px;overflow:hidden;width:88px}a.maplibregl-ctrl-logo.maplibregl-compact{width:14px}@media (forced-colors:active){a.maplibregl-ctrl-logo{background-color:transparent;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){a.maplibregl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E")}}.maplibregl-ctrl.maplibregl-ctrl-attrib{background-color:hsla(0,0%,100%,.5);margin:0;padding:0 5px}@media screen{.maplibregl-ctrl-attrib.maplibregl-compact{background-color:#fff;border-radius:12px;box-sizing:content-box;color:#000;margin:10px;min-height:20px;padding:2px 24px 2px 0;position:relative}.maplibregl-ctrl-attrib.maplibregl-compact-show{padding:2px 28px 2px 8px;visibility:visible}.maplibregl-ctrl-bottom-left>.maplibregl-ctrl-attrib.maplibregl-compact-show,.maplibregl-ctrl-top-left>.maplibregl-ctrl-attrib.maplibregl-compact-show{border-radius:12px;padding:2px 8px 2px 28px}.maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-inner{display:none}.maplibregl-ctrl-attrib-button{background-color:hsla(0,0%,100%,.5);background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E");border:0;border-radius:12px;box-sizing:border-box;cursor:pointer;display:none;height:24px;outline:none;position:absolute;right:0;top:0;width:24px}.maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button{-webkit-appearance:none;-moz-appearance:none;appearance:none;list-style:none}.maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button::-webkit-details-marker{display:none}.maplibregl-ctrl-bottom-left .maplibregl-ctrl-attrib-button,.maplibregl-ctrl-top-left .maplibregl-ctrl-attrib-button{left:0}.maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-button,.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-inner{display:block}.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-button{background-color:rgb(0 0 0/5%)}.maplibregl-ctrl-bottom-right>.maplibregl-ctrl-attrib.maplibregl-compact:after{bottom:0;right:0}.maplibregl-ctrl-top-right>.maplibregl-ctrl-attrib.maplibregl-compact:after{right:0;top:0}.maplibregl-ctrl-top-left>.maplibregl-ctrl-attrib.maplibregl-compact:after{left:0;top:0}.maplibregl-ctrl-bottom-left>.maplibregl-ctrl-attrib.maplibregl-compact:after{bottom:0;left:0}}@media screen and (forced-colors:active){.maplibregl-ctrl-attrib.maplibregl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='%23fff' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}@media screen and (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl-attrib.maplibregl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}.maplibregl-ctrl-attrib a{color:rgba(0,0,0,.75);text-decoration:none}.maplibregl-ctrl-attrib a:hover{color:inherit;text-decoration:underline}.maplibregl-attrib-empty{display:none}.maplibregl-ctrl-scale{background-color:hsla(0,0%,100%,.75);border:2px solid #333;border-top:#333;box-sizing:border-box;color:#333;font-size:10px;padding:0 5px}.maplibregl-popup{display:flex;left:0;pointer-events:none;position:absolute;top:0;will-change:transform}.maplibregl-popup-anchor-top,.maplibregl-popup-anchor-top-left,.maplibregl-popup-anchor-top-right{flex-direction:column}.maplibregl-popup-anchor-bottom,.maplibregl-popup-anchor-bottom-left,.maplibregl-popup-anchor-bottom-right{flex-direction:column-reverse}.maplibregl-popup-anchor-left{flex-direction:row}.maplibregl-popup-anchor-right{flex-direction:row-reverse}.maplibregl-popup-tip{border:10px solid transparent;height:0;width:0;z-index:1}.maplibregl-popup-anchor-top .maplibregl-popup-tip{align-self:center;border-bottom-color:#fff;border-top:none}.maplibregl-popup-anchor-top-left .maplibregl-popup-tip{align-self:flex-start;border-bottom-color:#fff;border-left:none;border-top:none}.maplibregl-popup-anchor-top-right .maplibregl-popup-tip{align-self:flex-end;border-bottom-color:#fff;border-right:none;border-top:none}.maplibregl-popup-anchor-bottom .maplibregl-popup-tip{align-self:center;border-bottom:none;border-top-color:#fff}.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip{align-self:flex-start;border-bottom:none;border-left:none;border-top-color:#fff}.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip{align-self:flex-end;border-bottom:none;border-right:none;border-top-color:#fff}.maplibregl-popup-anchor-left .maplibregl-popup-tip{align-self:center;border-left:none;border-right-color:#fff}.maplibregl-popup-anchor-right .maplibregl-popup-tip{align-self:center;border-left-color:#fff;border-right:none}.maplibregl-popup-close-button{background-color:transparent;border:0;border-radius:0 3px 0 0;cursor:pointer;position:absolute;right:0;top:0}.maplibregl-popup-close-button:hover{background-color:rgb(0 0 0/5%)}.maplibregl-popup-content{background:#fff;border-radius:3px;box-shadow:0 1px 2px rgba(0,0,0,.1);padding:15px 10px;pointer-events:auto;position:relative}.maplibregl-popup-anchor-top-left .maplibregl-popup-content{border-top-left-radius:0}.maplibregl-popup-anchor-top-right .maplibregl-popup-content{border-top-right-radius:0}.maplibregl-popup-anchor-bottom-left .maplibregl-popup-content{border-bottom-left-radius:0}.maplibregl-popup-anchor-bottom-right .maplibregl-popup-content{border-bottom-right-radius:0}.maplibregl-popup-track-pointer{display:none}.maplibregl-popup-track-pointer *{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.maplibregl-map:hover .maplibregl-popup-track-pointer{display:flex}.maplibregl-map:active .maplibregl-popup-track-pointer{display:none}.maplibregl-marker{left:0;position:absolute;top:0;transition:opacity .2s;will-change:transform}.maplibregl-user-location-dot,.maplibregl-user-location-dot:before{background-color:#1da1f2;border-radius:50%;height:15px;width:15px}.maplibregl-user-location-dot:before{animation:maplibregl-user-location-dot-pulse 2s infinite;content:"";position:absolute}.maplibregl-user-location-dot:after{border:2px solid #fff;border-radius:50%;box-shadow:0 0 3px rgba(0,0,0,.35);box-sizing:border-box;content:"";height:19px;left:-2px;position:absolute;top:-2px;width:19px}@keyframes maplibregl-user-location-dot-pulse{0%{opacity:1;transform:scale(1)}70%{opacity:0;transform:scale(3)}to{opacity:0;transform:scale(1)}}.maplibregl-user-location-dot-stale{background-color:#aaa}.maplibregl-user-location-dot-stale:after{display:none}.maplibregl-user-location-accuracy-circle{background-color:#1da1f233;border-radius:100%;height:1px;width:1px}.maplibregl-crosshair,.maplibregl-crosshair .maplibregl-interactive,.maplibregl-crosshair .maplibregl-interactive:active{cursor:crosshair}.maplibregl-boxzoom{background:#fff;border:2px dotted #202020;height:0;left:0;opacity:.5;position:absolute;top:0;width:0}.maplibregl-cooperative-gesture-screen{align-items:center;background:rgba(0,0,0,.4);color:#fff;display:flex;font-size:1.4em;inset:0;justify-content:center;line-height:1.2;opacity:0;padding:1rem;pointer-events:none;position:absolute;transition:opacity 1s ease 1s;z-index:99999}.maplibregl-cooperative-gesture-screen.maplibregl-show{opacity:1;transition:opacity .05s}.maplibregl-cooperative-gesture-screen .maplibregl-mobile-message{display:none}@media (hover:none),(width <= 480px){.maplibregl-cooperative-gesture-screen .maplibregl-desktop-message{display:none}.maplibregl-cooperative-gesture-screen .maplibregl-mobile-message{display:block}}.maplibregl-pseudo-fullscreen{height:100%!important;left:0!important;position:fixed!important;top:0!important;width:100%!important;z-index:99999} \ No newline at end of file diff --git a/src/lib/better_launch/docs/benchmarks/results/psutil/psutil_bl.csv b/src/lib/better_launch/docs/benchmarks/results/psutil/psutil_bl.csv new file mode 100644 index 0000000000..69658a1ed0 --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/results/psutil/psutil_bl.csv @@ -0,0 +1,44 @@ +time_s,cpu_%,memory_mb +0.10099077224731445,89.8,18.85546875 +0.20591306686401367,725.7,35.07421875 +0.30667853355407715,99.9,17.14453125 +0.41089773178100586,535.8,34.56640625 +0.5123322010040283,249.6,51.4921875 +0.6137382984161377,0.0,44.8359375 +0.7154080867767334,30.0,46.1171875 +0.817150354385376,0.0,46.1171875 +0.9196758270263672,0.0,46.1171875 +1.0223329067230225,0.0,46.1171875 +1.1254034042358398,0.0,46.1171875 +1.2282602787017822,0.0,46.1171875 +1.3311049938201904,0.0,46.1171875 +1.434112310409546,0.0,46.1171875 +1.5367305278778076,0.0,46.12109375 +1.6394414901733398,0.0,46.12109375 +1.7421700954437256,0.0,46.12109375 +1.8447046279907227,0.0,46.12109375 +1.9474537372589111,0.0,46.12109375 +2.050260543823242,0.0,46.12109375 +2.1523492336273193,0.0,46.12109375 +2.254302501678467,0.0,46.12109375 +2.3569865226745605,0.0,46.12109375 +2.459766149520874,0.0,46.12109375 +2.5625815391540527,0.0,46.12109375 +2.6655433177948,0.0,46.12109375 +2.768280029296875,0.0,46.21875 +2.869965076446533,0.0,46.21875 +2.972813367843628,0.0,46.21875 +3.0754833221435547,0.0,46.21875 +3.1782495975494385,0.0,46.21875 +3.2808713912963867,0.0,46.21875 +3.383716583251953,0.0,46.21875 +3.486342430114746,0.0,46.21875 +3.589028835296631,0.0,46.21875 +3.691742181777954,10.0,46.21875 +3.7944552898406982,0.0,46.21875 +3.897052764892578,0.0,46.21875 +4.0012218952178955,0.0,46.21875 +4.103874683380127,0.0,46.21875 +4.206702470779419,0.0,46.21875 +4.309184312820435,0.0,46.21875 +4.411885023117065,0.0,46.21875 diff --git a/src/lib/better_launch/docs/benchmarks/results/psutil/psutil_python.csv b/src/lib/better_launch/docs/benchmarks/results/psutil/psutil_python.csv new file mode 100644 index 0000000000..4d69e69cab --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/results/psutil/psutil_python.csv @@ -0,0 +1,48 @@ +time_s,cpu_%,memory_mb +0.10942268371582031,99.8,17.48046875 +0.21028590202331543,39.9,21.8359375 +0.31114840507507324,0.0,21.8046875 +0.41265058517456055,0.0,21.8046875 +0.5138106346130371,0.0,21.8046875 +0.6153838634490967,0.0,21.8046875 +0.7169785499572754,0.0,21.8046875 +0.8183529376983643,0.0,21.81640625 +0.9198172092437744,0.0,21.828125 +1.0215744972229004,0.0,21.828125 +1.1232082843780518,0.0,21.828125 +1.224787950515747,0.0,21.828125 +1.3264400959014893,0.0,21.8359375 +1.4280643463134766,0.0,21.8359375 +1.5297765731811523,0.0,21.8359375 +1.6314213275909424,0.0,21.8359375 +1.7331137657165527,0.0,21.8359375 +1.83500337600708,0.0,21.8359375 +1.9365551471710205,0.0,21.8359375 +2.038161516189575,0.0,21.8359375 +2.139749765396118,0.0,21.8359375 +2.2414748668670654,0.0,21.8359375 +2.3430635929107666,0.0,21.83984375 +2.44461727142334,0.0,21.83984375 +2.546217918395996,0.0,21.83984375 +2.647768259048462,0.0,21.83984375 +2.7493278980255127,0.0,21.83984375 +2.8510942459106445,0.0,21.84375 +2.9528379440307617,0.0,21.84375 +3.054636240005493,0.0,21.84375 +3.1562399864196777,0.0,21.84375 +3.258009672164917,0.0,21.84375 +3.3594772815704346,10.0,21.84375 +3.4612011909484863,0.0,21.84375 +3.5630598068237305,0.0,21.84375 +3.664761781692505,0.0,21.84375 +3.767289876937866,0.0,21.84375 +3.869051933288574,0.0,21.84375 +3.9709246158599854,0.0,21.84375 +4.072721242904663,0.0,21.84375 +4.174153566360474,0.0,21.84375 +4.27592396736145,0.0,21.84375 +4.377454996109009,0.0,21.84375 +4.479041814804077,0.0,21.84375 +4.580241441726685,0.0,21.84375 +4.681861877441406,0.0,21.84375 +4.783490180969238,0.0,21.84375 diff --git a/src/lib/better_launch/docs/benchmarks/results/psutil/psutil_ros2.csv b/src/lib/better_launch/docs/benchmarks/results/psutil/psutil_ros2.csv new file mode 100644 index 0000000000..ab036975be --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/results/psutil/psutil_ros2.csv @@ -0,0 +1,48 @@ +time_s,cpu_%,memory_mb +0.1007375717163086,79.9,19.23828125 +0.20204830169677734,99.9,24.02734375 +0.3035166263580322,0.0,21.72265625 +0.4053535461425781,0.0,21.66796875 +0.5071208477020264,0.0,21.66796875 +0.6087555885314941,0.0,21.66796875 +0.7103781700134277,0.0,21.66796875 +0.8113608360290527,0.0,21.66796875 +0.9129054546356201,0.0,21.66796875 +1.014671802520752,0.0,21.66796875 +1.116424322128296,0.0,21.66796875 +1.2182586193084717,0.0,21.66796875 +1.3198707103729248,0.0,21.66796875 +1.4214680194854736,0.0,21.66796875 +1.5231666564941406,0.0,21.66796875 +1.6248369216918945,0.0,21.66796875 +1.7264580726623535,0.0,21.66796875 +1.8274328708648682,0.0,21.66796875 +1.9289891719818115,0.0,21.66796875 +2.0305633544921875,0.0,21.66796875 +2.132169485092163,0.0,21.66796875 +2.233881711959839,0.0,21.66796875 +2.335469961166382,0.0,21.66796875 +2.437213897705078,0.0,21.66796875 +2.538377285003662,0.0,21.66796875 +2.6400372982025146,0.0,21.66796875 +2.7416863441467285,0.0,21.66796875 +2.843258857727051,0.0,21.66796875 +2.9450509548187256,0.0,21.66796875 +3.0467941761016846,0.0,21.66796875 +3.148395299911499,0.0,21.66796875 +3.250014305114746,0.0,21.66796875 +3.3524107933044434,0.0,21.66796875 +3.4541399478912354,0.0,21.66796875 +3.5558998584747314,0.0,21.66796875 +3.656930446624756,0.0,21.66796875 +3.7584307193756104,0.0,21.66796875 +3.8601131439208984,10.0,21.66796875 +3.9618420600891113,0.0,21.66796875 +4.063687086105347,0.0,21.66796875 +4.165390729904175,0.0,21.66796875 +4.267156600952148,0.0,21.66796875 +4.36881947517395,0.0,21.66796875 +4.4705870151519775,0.0,21.66796875 +4.572343111038208,0.0,21.66796875 +4.674035310745239,0.0,21.66796875 +4.775747060775757,0.0,21.66796875 diff --git a/src/lib/better_launch/docs/benchmarks/results/pyspy/bl.svg b/src/lib/better_launch/docs/benchmarks/results/pyspy/bl.svg new file mode 100644 index 0000000000..7e97f264d1 --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/results/pyspy/bl.svg @@ -0,0 +1,491 @@ +py-spy record --format flamegraph -o ./results/pyspy/bl.svg -- python ../../examples/11_performance.launch.py Reset ZoomSearch get_code (<frozen importlib._bootstrap_external>:957) (2 samples, 13.33%)get_code (<frozen im.._check_name_wrapper (<frozen importlib._bootstrap_external>:548) (2 samples, 13.33%)_check_name_wrapper ..<module> (click/types.py:11) (3 samples, 20.00%)<module> (click/types.py:11)_find_and_load (<frozen importlib._bootstrap>:1027) (3 samples, 20.00%)_find_and_load (<frozen importl.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (3 samples, 20.00%)_find_and_load_unlocked (<froze.._load_unlocked (<frozen importlib._bootstrap>:688) (3 samples, 20.00%)_load_unlocked (<frozen importl..exec_module (<frozen importlib._bootstrap_external>:883) (3 samples, 20.00%)exec_module (<frozen importlib..._call_with_frames_removed (<frozen importlib._bootstrap>:241) (3 samples, 20.00%)_call_with_frames_removed (<fro..<module> (click/exceptions.py:7) (3 samples, 20.00%)<module> (click/exceptions.py:7)_find_and_load (<frozen importlib._bootstrap>:1027) (3 samples, 20.00%)_find_and_load (<frozen importl.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (3 samples, 20.00%)_find_and_load_unlocked (<froze.._load_unlocked (<frozen importlib._bootstrap>:688) (3 samples, 20.00%)_load_unlocked (<frozen importl..exec_module (<frozen importlib._bootstrap_external>:879) (3 samples, 20.00%)exec_module (<frozen importlib...get_code (<frozen importlib._bootstrap_external>:984) (1 samples, 6.67%)get_code .._classify_pyc (<frozen importlib._bootstrap_external>:602) (1 samples, 6.67%)_classify..<module> (better_launch/wrapper.py:7) (4 samples, 26.67%)<module> (better_launch/wrapper.py:7)_find_and_load (<frozen importlib._bootstrap>:1027) (4 samples, 26.67%)_find_and_load (<frozen importlib._bootstra.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (4 samples, 26.67%)_find_and_load_unlocked (<frozen importlib..._load_unlocked (<frozen importlib._bootstrap>:688) (4 samples, 26.67%)_load_unlocked (<frozen importlib._bootstra..exec_module (<frozen importlib._bootstrap_external>:883) (4 samples, 26.67%)exec_module (<frozen importlib._bootstrap_e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (4 samples, 26.67%)_call_with_frames_removed (<frozen importli..<module> (click/__init__.py:8) (4 samples, 26.67%)<module> (click/__init__.py:8)_find_and_load (<frozen importlib._bootstrap>:1027) (4 samples, 26.67%)_find_and_load (<frozen importlib._bootstra.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (4 samples, 26.67%)_find_and_load_unlocked (<frozen importlib..._load_unlocked (<frozen importlib._bootstrap>:688) (4 samples, 26.67%)_load_unlocked (<frozen importlib._bootstra..exec_module (<frozen importlib._bootstrap_external>:883) (4 samples, 26.67%)exec_module (<frozen importlib._bootstrap_e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (4 samples, 26.67%)_call_with_frames_removed (<frozen importli..<module> (click/core.py:16) (4 samples, 26.67%)<module> (click/core.py:16)_handle_fromlist (<frozen importlib._bootstrap>:1078) (4 samples, 26.67%)_handle_fromlist (<frozen importlib._bootst.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (4 samples, 26.67%)_call_with_frames_removed (<frozen importli.._find_and_load (<frozen importlib._bootstrap>:1027) (4 samples, 26.67%)_find_and_load (<frozen importlib._bootstra.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (4 samples, 26.67%)_find_and_load_unlocked (<frozen importlib..._load_unlocked (<frozen importlib._bootstrap>:688) (4 samples, 26.67%)_load_unlocked (<frozen importlib._bootstra..exec_module (<frozen importlib._bootstrap_external>:883) (4 samples, 26.67%)exec_module (<frozen importlib._bootstrap_e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (4 samples, 26.67%)_call_with_frames_removed (<frozen importli..<module> (click/types.py:24) (1 samples, 6.67%)<module> ..ParamType (click/types.py:54) (1 samples, 6.67%)ParamType..inner (typing.py:309) (1 samples, 6.67%)inner (ty..__getitem__ (typing.py:403) (1 samples, 6.67%)__getitem..ClassVar (typing.py:459) (1 samples, 6.67%)ClassVar ..<module> (better_launch/__init__.py:10) (5 samples, 33.33%)<module> (better_launch/__init__.py:10)_find_and_load (<frozen importlib._bootstrap>:1027) (5 samples, 33.33%)_find_and_load (<frozen importlib._bootstrap>:1027)_find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (5 samples, 33.33%)_find_and_load_unlocked (<frozen importlib._bootstrap>.._load_unlocked (<frozen importlib._bootstrap>:688) (5 samples, 33.33%)_load_unlocked (<frozen importlib._bootstrap>:688)exec_module (<frozen importlib._bootstrap_external>:883) (5 samples, 33.33%)exec_module (<frozen importlib._bootstrap_external>:88.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (5 samples, 33.33%)_call_with_frames_removed (<frozen importlib._bootstra..<module> (better_launch/wrapper.py:9) (1 samples, 6.67%)<module> .._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 6.67%)exec_modu.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit..<module> (docstring_parser/__init__.py:14) (1 samples, 6.67%)<module> .._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 6.67%)exec_modu.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit..<module> (docstring_parser/parser.py:6) (1 samples, 6.67%)<module> .._handle_fromlist (<frozen importlib._bootstrap>:1078) (1 samples, 6.67%)_handle_f.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1002) (1 samples, 6.67%)_find_and.._find_spec (<frozen importlib._bootstrap>:945) (1 samples, 6.67%)_find_spe..find_spec (<frozen importlib._bootstrap_external>:1439) (1 samples, 6.67%)find_spec.._get_spec (<frozen importlib._bootstrap_external>:1411) (1 samples, 6.67%)_get_spec..find_spec (<frozen importlib._bootstrap_external>:1544) (1 samples, 6.67%)find_spec..<module> (rclpy/node.py:58) (1 samples, 6.67%)<module> .._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 6.67%)exec_modu.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit..<module> (rclpy/executors.py:18) (1 samples, 6.67%)<module> .._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 6.67%)exec_modu.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit..<module> (multiprocessing/__init__.py:16) (1 samples, 6.67%)<module> .._handle_fromlist (<frozen importlib._bootstrap>:1078) (1 samples, 6.67%)_handle_f.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 6.67%)exec_modu.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit..<module> (multiprocessing/context.py:6) (1 samples, 6.67%)<module> .._handle_fromlist (<frozen importlib._bootstrap>:1078) (1 samples, 6.67%)_handle_f.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 6.67%)exec_modu.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit..<module> (multiprocessing/reduction.py:16) (1 samples, 6.67%)<module> .._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 6.67%)exec_modu.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit..<module> (socket.py:54) (1 samples, 6.67%)<module> .._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 6.67%)exec_modu.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit..<module> (selectors.py:12) (1 samples, 6.67%)<module> .._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:674) (1 samples, 6.67%)_load_unl..module_from_spec (<frozen importlib._bootstrap>:571) (1 samples, 6.67%)module_fr..create_module (<frozen importlib._bootstrap_external>:1176) (1 samples, 6.67%)create_mo.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (2 samples, 13.33%)_find_and_load_unloc.._load_unlocked (<frozen importlib._bootstrap>:688) (2 samples, 13.33%)_load_unlocked (<fro..exec_module (<frozen importlib._bootstrap_external>:883) (2 samples, 13.33%)exec_module (<frozen.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (2 samples, 13.33%)_call_with_frames_re..<module> (rclpy/node.py:83) (1 samples, 6.67%)<module> .._find_and_load (<frozen importlib._bootstrap>:1024) (1 samples, 6.67%)_find_and..__enter__ (<frozen importlib._bootstrap>:170) (1 samples, 6.67%)__enter__.._get_module_lock (<frozen importlib._bootstrap>:196) (1 samples, 6.67%)_get_modu..__init__ (<frozen importlib._bootstrap>:72) (1 samples, 6.67%)__init__ ..<module> (rclpy/__init__.py:48) (1 samples, 6.67%)<module> .._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 6.67%)exec_modu.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit..<module> (rclpy/parameter.py:18) (1 samples, 6.67%)<module> .._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 6.67%)exec_modu.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit..<module> (rcl_interfaces/msg/__init__.py:7) (1 samples, 6.67%)<module> .._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:879) (1 samples, 6.67%)exec_modu..get_code (<frozen importlib._bootstrap_external>:1018) (1 samples, 6.67%)get_code ..source_to_code (<frozen importlib._bootstrap_external>:947) (1 samples, 6.67%)source_to.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit..<module> (better_launch/launcher.py:16) (4 samples, 26.67%)<module> (better_launch/launcher.py:16)_find_and_load (<frozen importlib._bootstrap>:1027) (4 samples, 26.67%)_find_and_load (<frozen importlib._bootstra.._find_and_load_unlocked (<frozen importlib._bootstrap>:992) (2 samples, 13.33%)_find_and_load_unloc.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (2 samples, 13.33%)_call_with_frames_re.._find_and_load (<frozen importlib._bootstrap>:1027) (2 samples, 13.33%)_find_and_load (<fro.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (2 samples, 13.33%)_find_and_load_unloc.._load_unlocked (<frozen importlib._bootstrap>:688) (2 samples, 13.33%)_load_unlocked (<fro..exec_module (<frozen importlib._bootstrap_external>:883) (2 samples, 13.33%)exec_module (<frozen.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (2 samples, 13.33%)_call_with_frames_re..<module> (rclpy/__init__.py:49) (1 samples, 6.67%)<module> .._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:879) (1 samples, 6.67%)exec_modu..get_code (<frozen importlib._bootstrap_external>:1018) (1 samples, 6.67%)get_code ..source_to_code (<frozen importlib._bootstrap_external>:947) (1 samples, 6.67%)source_to.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit..<module> (better_launch/elements/node.py:13) (1 samples, 6.67%)<module> .._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 6.67%)exec_modu.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit..<module> (pprint.py:38) (1 samples, 6.67%)<module> .._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 6.67%)exec_modu.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit..<module> (dataclasses.py:3) (1 samples, 6.67%)<module> .._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:879) (1 samples, 6.67%)exec_modu..get_code (<frozen importlib._bootstrap_external>:1015) (1 samples, 6.67%)get_code .._compile_bytecode (<frozen importlib._bootstrap_external>:672) (1 samples, 6.67%)_compile_..<module> (better_launch/elements/__init__.py:1) (2 samples, 13.33%)<module> (better_lau.._find_and_load (<frozen importlib._bootstrap>:1027) (2 samples, 13.33%)_find_and_load (<fro.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (2 samples, 13.33%)_find_and_load_unloc.._load_unlocked (<frozen importlib._bootstrap>:688) (2 samples, 13.33%)_load_unlocked (<fro..exec_module (<frozen importlib._bootstrap_external>:883) (2 samples, 13.33%)exec_module (<frozen.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (2 samples, 13.33%)_call_with_frames_re..<module> (better_launch/elements/group.py:1) (2 samples, 13.33%)<module> (better_lau.._find_and_load (<frozen importlib._bootstrap>:1027) (2 samples, 13.33%)_find_and_load (<fro.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (2 samples, 13.33%)_find_and_load_unloc.._load_unlocked (<frozen importlib._bootstrap>:688) (2 samples, 13.33%)_load_unlocked (<fro..exec_module (<frozen importlib._bootstrap_external>:883) (2 samples, 13.33%)exec_module (<frozen.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (2 samples, 13.33%)_call_with_frames_re..<module> (better_launch/elements/node.py:15) (1 samples, 6.67%)<module> .._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 6.67%)exec_modu.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit..<module> (json/__init__.py:106) (1 samples, 6.67%)<module> .._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 6.67%)exec_modu.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit..<module> (json/decoder.py:5) (1 samples, 6.67%)<module> .._handle_fromlist (<frozen importlib._bootstrap>:1078) (1 samples, 6.67%)_handle_f.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 6.67%)exec_modu.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit..<module> (json/scanner.py:13) (1 samples, 6.67%)<module> ..__or__ (enum.py:983) (1 samples, 6.67%)__or__ (e..__call__ (enum.py:385) (1 samples, 6.67%)__call__ ..<module> (better_launch/elements/foreign_node.py:4) (1 samples, 6.67%)<module> .._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 6.67%)exec_modu.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit..<module> (psutil/__init__.py:103) (1 samples, 6.67%)<module> .._handle_fromlist (<frozen importlib._bootstrap>:1078) (1 samples, 6.67%)_handle_f.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 6.67%)exec_modu.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit..<module> (psutil/_pslinux.py:1732) (1 samples, 6.67%)<module> ..Process (psutil/_pslinux.py:2367) (1 samples, 6.67%)Process (..compile (re.py:251) (1 samples, 6.67%)compile (.._compile (re.py:303) (1 samples, 6.67%)_compile ..compile (sre_compile.py:792) (1 samples, 6.67%)compile (.._code (sre_compile.py:631) (1 samples, 6.67%)_code (sr.._compile (sre_compile.py:135) (1 samples, 6.67%)_compile ..<module> (11_performance.launch.py:2) (13 samples, 86.67%)<module> (11_performance.launch.py:2)_find_and_load (<frozen importlib._bootstrap>:1027) (13 samples, 86.67%)_find_and_load (<frozen importlib._bootstrap>:1027)_find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (13 samples, 86.67%)_find_and_load_unlocked (<frozen importlib._bootstrap>:1006)_load_unlocked (<frozen importlib._bootstrap>:688) (13 samples, 86.67%)_load_unlocked (<frozen importlib._bootstrap>:688)exec_module (<frozen importlib._bootstrap_external>:883) (13 samples, 86.67%)exec_module (<frozen importlib._bootstrap_external>:883)_call_with_frames_removed (<frozen importlib._bootstrap>:241) (13 samples, 86.67%)_call_with_frames_removed (<frozen importlib._bootstrap>:241)<module> (better_launch/__init__.py:9) (8 samples, 53.33%)<module> (better_launch/__init__.py:9)_find_and_load (<frozen importlib._bootstrap>:1027) (8 samples, 53.33%)_find_and_load (<frozen importlib._bootstrap>:1027)_find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (8 samples, 53.33%)_find_and_load_unlocked (<frozen importlib._bootstrap>:1006)_load_unlocked (<frozen importlib._bootstrap>:688) (8 samples, 53.33%)_load_unlocked (<frozen importlib._bootstrap>:688)exec_module (<frozen importlib._bootstrap_external>:883) (8 samples, 53.33%)exec_module (<frozen importlib._bootstrap_external>:883)_call_with_frames_removed (<frozen importlib._bootstrap>:241) (8 samples, 53.33%)_call_with_frames_removed (<frozen importlib._bootstrap>:241)<module> (better_launch/launcher.py:45) (4 samples, 26.67%)<module> (better_launch/launcher.py:45)_find_and_load (<frozen importlib._bootstrap>:1027) (4 samples, 26.67%)_find_and_load (<frozen importlib._bootstra.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (4 samples, 26.67%)_find_and_load_unlocked (<frozen importlib..._load_unlocked (<frozen importlib._bootstrap>:688) (4 samples, 26.67%)_load_unlocked (<frozen importlib._bootstra..exec_module (<frozen importlib._bootstrap_external>:883) (4 samples, 26.67%)exec_module (<frozen importlib._bootstrap_e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (4 samples, 26.67%)_call_with_frames_removed (<frozen importli..<module> (better_launch/elements/__init__.py:6) (2 samples, 13.33%)<module> (better_lau.._find_and_load (<frozen importlib._bootstrap>:1027) (2 samples, 13.33%)_find_and_load (<fro.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (2 samples, 13.33%)_find_and_load_unloc.._load_unlocked (<frozen importlib._bootstrap>:688) (2 samples, 13.33%)_load_unlocked (<fro..exec_module (<frozen importlib._bootstrap_external>:883) (2 samples, 13.33%)exec_module (<frozen.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (2 samples, 13.33%)_call_with_frames_re..<module> (better_launch/elements/foreign_node.py:8) (1 samples, 6.67%)<module> .._handle_fromlist (<frozen importlib._bootstrap>:1078) (1 samples, 6.67%)_handle_f.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 6.67%)_load_unl..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 6.67%)exec_modu.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit..<module> (xml/etree/ElementTree.py:2092) (1 samples, 6.67%)<module> .._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:674) (1 samples, 6.67%)_load_unl..module_from_spec (<frozen importlib._bootstrap>:571) (1 samples, 6.67%)module_fr..create_module (<frozen importlib._bootstrap_external>:1176) (1 samples, 6.67%)create_mo.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 6.67%)_find_and.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 6.67%)_find_and.._load_unlocked (<frozen importlib._bootstrap>:674) (1 samples, 6.67%)_load_unl..module_from_spec (<frozen importlib._bootstrap>:571) (1 samples, 6.67%)module_fr..create_module (<frozen importlib._bootstrap_external>:1176) (1 samples, 6.67%)create_mo.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 6.67%)_call_wit.._launch_this_wrapper (better_launch/wrapper.py:352) (1 samples, 6.67%)_launch_t..main (click/core.py:1082) (1 samples, 6.67%)main (cli..invoke (click/core.py:1443) (1 samples, 6.67%)invoke (c..invoke (click/core.py:787) (1 samples, 6.67%)invoke (c..new_func (click/decorators.py:33) (1 samples, 6.67%)new_func ..run (better_launch/wrapper.py:337) (1 samples, 6.67%)run (bett..launch_func_wrapper (better_launch/wrapper.py:320) (1 samples, 6.67%)launch_fu..spin (better_launch/launcher.py:238) (1 samples, 6.67%)spin (bet..result (concurrent/futures/_base.py:460) (1 samples, 6.67%)result (c..all (15 samples, 100%)<module> (11_performance.launch.py:6) (2 samples, 13.33%)<module> (11_perform..launch_this (better_launch/wrapper.py:80) (2 samples, 13.33%)launch_this (better_..decoration_helper (better_launch/wrapper.py:69) (2 samples, 13.33%)decoration_helper (b.._launch_this_wrapper (better_launch/wrapper.py:94) (1 samples, 6.67%)_launch_t..find_calling_frame (better_launch/utils/introspection.py:56) (1 samples, 6.67%)find_call..stack (inspect.py:1673) (1 samples, 6.67%)stack (in..getouterframes (inspect.py:1650) (1 samples, 6.67%)getouterf..getframeinfo (inspect.py:1624) (1 samples, 6.67%)getframei..findsource (inspect.py:952) (1 samples, 6.67%)findsourc..getmodule (inspect.py:878) (1 samples, 6.67%)getmodule..realpath (posixpath.py:397) (1 samples, 6.67%)realpath ..abspath (posixpath.py:386) (1 samples, 6.67%)abspath (..normpath (posixpath.py:363) (1 samples, 6.67%)normpath .. \ No newline at end of file diff --git a/src/lib/better_launch/docs/benchmarks/results/pyspy/ros2.svg b/src/lib/better_launch/docs/benchmarks/results/pyspy/ros2.svg new file mode 100644 index 0000000000..8898b80484 --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/results/pyspy/ros2.svg @@ -0,0 +1,491 @@ +py-spy record --format flamegraph -o ./results/pyspy/ros2.svg -- ros2 launch better_launch ros2_performance.launch.py Reset ZoomSearch _find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 1.89%)e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_..<module> (rclpy/executors.py:18) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1002) (1 samples, 1.89%)_.._find_spec (<frozen importlib._bootstrap>:945) (1 samples, 1.89%)_..find_spec (<frozen importlib._bootstrap_external>:1439) (1 samples, 1.89%)f.._get_spec (<frozen importlib._bootstrap_external>:1411) (1 samples, 1.89%)_..find_spec (<frozen importlib._bootstrap_external>:1544) (1 samples, 1.89%)f.._path_stat (<frozen importlib._bootstrap_external>:147) (1 samples, 1.89%)_..<module> (rcl_interfaces/msg/__init__.py:1) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 1.89%)e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_..<module> (rcl_interfaces/msg/_floating_point_range.py:12) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:879) (1 samples, 1.89%)e..get_code (<frozen importlib._bootstrap_external>:1018) (1 samples, 1.89%)g..source_to_code (<frozen importlib._bootstrap_external>:947) (1 samples, 1.89%)s.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_..<module> (rcl_interfaces/msg/__init__.py:5) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1002) (1 samples, 1.89%)_.._find_spec (<frozen importlib._bootstrap>:945) (1 samples, 1.89%)_..find_spec (<frozen importlib._bootstrap_external>:1439) (1 samples, 1.89%)f.._get_spec (<frozen importlib._bootstrap_external>:1411) (1 samples, 1.89%)_..find_spec (<frozen importlib._bootstrap_external>:1544) (1 samples, 1.89%)f.._path_stat (<frozen importlib._bootstrap_external>:147) (1 samples, 1.89%)_..get_code (<frozen importlib._bootstrap_external>:1017) (1 samples, 1.89%)g..importlib_load_entry_point (ros2:25) (5 samples, 9.43%)importlib_loa..load (importlib/metadata/__init__.py:171) (5 samples, 9.43%)load (importl..import_module (importlib/__init__.py:126) (5 samples, 9.43%)import_module.._gcd_import (<frozen importlib._bootstrap>:1050) (5 samples, 9.43%)_gcd_import (.._find_and_load (<frozen importlib._bootstrap>:1027) (5 samples, 9.43%)_find_and_loa.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (5 samples, 9.43%)_find_and_loa.._load_unlocked (<frozen importlib._bootstrap>:688) (5 samples, 9.43%)_load_unlocke..exec_module (<frozen importlib._bootstrap_external>:883) (5 samples, 9.43%)exec_module (.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (5 samples, 9.43%)_call_with_fr..<module> (ros2cli/cli.py:22) (5 samples, 9.43%)<module> (ros.._find_and_load (<frozen importlib._bootstrap>:1027) (5 samples, 9.43%)_find_and_loa.._find_and_load_unlocked (<frozen importlib._bootstrap>:992) (4 samples, 7.55%)_find_and_.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (4 samples, 7.55%)_call_with.._find_and_load (<frozen importlib._bootstrap>:1027) (4 samples, 7.55%)_find_and_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (4 samples, 7.55%)_find_and_.._load_unlocked (<frozen importlib._bootstrap>:688) (4 samples, 7.55%)_load_unlo..exec_module (<frozen importlib._bootstrap_external>:883) (4 samples, 7.55%)exec_modul.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (4 samples, 7.55%)_call_with..<module> (rclpy/__init__.py:48) (4 samples, 7.55%)<module> (.._find_and_load (<frozen importlib._bootstrap>:1027) (4 samples, 7.55%)_find_and_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (4 samples, 7.55%)_find_and_.._load_unlocked (<frozen importlib._bootstrap>:688) (4 samples, 7.55%)_load_unlo..exec_module (<frozen importlib._bootstrap_external>:883) (4 samples, 7.55%)exec_modul.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (4 samples, 7.55%)_call_with..<module> (rclpy/parameter.py:18) (4 samples, 7.55%)<module> (.._find_and_load (<frozen importlib._bootstrap>:1027) (4 samples, 7.55%)_find_and_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (4 samples, 7.55%)_find_and_.._load_unlocked (<frozen importlib._bootstrap>:688) (4 samples, 7.55%)_load_unlo..exec_module (<frozen importlib._bootstrap_external>:883) (4 samples, 7.55%)exec_modul.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (4 samples, 7.55%)_call_with..<module> (rcl_interfaces/msg/__init__.py:8) (2 samples, 3.77%)<mod.._find_and_load (<frozen importlib._bootstrap>:1027) (2 samples, 3.77%)_fin.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (2 samples, 3.77%)_fin.._load_unlocked (<frozen importlib._bootstrap>:688) (2 samples, 3.77%)_loa..exec_module (<frozen importlib._bootstrap_external>:879) (2 samples, 3.77%)exec..get_code (<frozen importlib._bootstrap_external>:1018) (1 samples, 1.89%)g..source_to_code (<frozen importlib._bootstrap_external>:947) (1 samples, 1.89%)s.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_..add_subparsers_on_demand (ros2cli/command/__init__.py:191) (1 samples, 1.89%)a..add_parser (argparse.py:1197) (1 samples, 1.89%)a..__init__ (argparse.py:1748) (1 samples, 1.89%)_..add_argument_group (argparse.py:1463) (1 samples, 1.89%)a..__init__ (argparse.py:1646) (1 samples, 1.89%)_..__init__ (argparse.py:1353) (1 samples, 1.89%)_..register (argparse.py:1384) (1 samples, 1.89%)r..<module> (ros2launch/command/launch.py:20) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:992) (1 samples, 1.89%)_.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 1.89%)e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_..<module> (argcomplete/__init__.py:7) (1 samples, 1.89%)<.._handle_fromlist (<frozen importlib._bootstrap>:1078) (1 samples, 1.89%)_.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 1.89%)e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_..<module> (argcomplete/my_shlex.py:22) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1002) (1 samples, 1.89%)_.._find_spec (<frozen importlib._bootstrap>:945) (1 samples, 1.89%)_..find_spec (<frozen importlib._bootstrap_external>:1439) (1 samples, 1.89%)f.._get_spec (<frozen importlib._bootstrap_external>:1411) (1 samples, 1.89%)_..find_spec (<frozen importlib._bootstrap_external>:1572) (1 samples, 1.89%)f..<module> (launch/logging/__init__.py:30) (1 samples, 1.89%)<.._handle_fromlist (<frozen importlib._bootstrap>:1078) (1 samples, 1.89%)_.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:879) (1 samples, 1.89%)e..get_code (<frozen importlib._bootstrap_external>:1015) (1 samples, 1.89%)g.._compile_bytecode (<frozen importlib._bootstrap_external>:672) (1 samples, 1.89%)_..<module> (launch/frontend/__init__.py:17) (1 samples, 1.89%)<.._handle_fromlist (<frozen importlib._bootstrap>:1078) (1 samples, 1.89%)_.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 1.89%)e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_..<module> (launch/frontend/type_utils.py:22) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 1.89%)e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_..<module> (launch/frontend/entity.py:22) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 1.89%)e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_..<module> (launch/utilities/type_utils.py:29) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 1.89%)e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_..<module> (yaml/__init__.py:2) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1002) (1 samples, 1.89%)_.._find_spec (<frozen importlib._bootstrap>:945) (1 samples, 1.89%)_..find_spec (<frozen importlib._bootstrap_external>:1439) (1 samples, 1.89%)f.._get_spec (<frozen importlib._bootstrap_external>:1411) (1 samples, 1.89%)_..find_spec (<frozen importlib._bootstrap_external>:1544) (1 samples, 1.89%)f.._path_stat (<frozen importlib._bootstrap_external>:147) (1 samples, 1.89%)_..<module> (launch/actions/__init__.py:17) (3 samples, 5.66%)<module.._find_and_load (<frozen importlib._bootstrap>:1027) (3 samples, 5.66%)_find_a.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (3 samples, 5.66%)_find_a.._load_unlocked (<frozen importlib._bootstrap>:688) (3 samples, 5.66%)_load_u..exec_module (<frozen importlib._bootstrap_external>:883) (3 samples, 5.66%)exec_mo.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (3 samples, 5.66%)_call_w..<module> (launch/actions/declare_launch_argument.py:22) (3 samples, 5.66%)<module.._find_and_load (<frozen importlib._bootstrap>:1027) (3 samples, 5.66%)_find_a.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (3 samples, 5.66%)_find_a.._load_unlocked (<frozen importlib._bootstrap>:688) (3 samples, 5.66%)_load_u..exec_module (<frozen importlib._bootstrap_external>:883) (3 samples, 5.66%)exec_mo.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (3 samples, 5.66%)_call_w..<module> (launch/logging/__init__.py:32) (2 samples, 3.77%)<mod.._find_and_load (<frozen importlib._bootstrap>:1027) (2 samples, 3.77%)_fin.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (2 samples, 3.77%)_fin.._load_unlocked (<frozen importlib._bootstrap>:688) (2 samples, 3.77%)_loa..exec_module (<frozen importlib._bootstrap_external>:883) (2 samples, 3.77%)exec.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (2 samples, 3.77%)_cal..<module> (launch/frontend/__init__.py:20) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 1.89%)e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_..<module> (launch/frontend/parser.py:38) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 1.89%)e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_..<module> (launch/frontend/parse_substitution.py:23) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 1.89%)e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_..<module> (lark/__init__.py:1) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:674) (1 samples, 1.89%)_..module_from_spec (<frozen importlib._bootstrap>:577) (1 samples, 1.89%)m.._init_module_attrs (<frozen importlib._bootstrap>:556) (1 samples, 1.89%)_..cached (<frozen importlib._bootstrap>:397) (1 samples, 1.89%)c.._get_cached (<frozen importlib._bootstrap_external>:513) (1 samples, 1.89%)_..cache_from_source (<frozen importlib._bootstrap_external>:448) (1 samples, 1.89%)c.._path_join (<frozen importlib._bootstrap_external>:128) (1 samples, 1.89%)_..<listcomp> (<frozen importlib._bootstrap_external>:128) (1 samples, 1.89%)<..<module> (ros2launch/api/__init__.py:17) (4 samples, 7.55%)<module> (.._find_and_load (<frozen importlib._bootstrap>:1027) (4 samples, 7.55%)_find_and_.._find_and_load_unlocked (<frozen importlib._bootstrap>:992) (4 samples, 7.55%)_find_and_.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (4 samples, 7.55%)_call_with.._find_and_load (<frozen importlib._bootstrap>:1027) (4 samples, 7.55%)_find_and_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (4 samples, 7.55%)_find_and_.._load_unlocked (<frozen importlib._bootstrap>:688) (4 samples, 7.55%)_load_unlo..exec_module (<frozen importlib._bootstrap_external>:883) (4 samples, 7.55%)exec_modul.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (4 samples, 7.55%)_call_with..<module> (launch/__init__.py:17) (4 samples, 7.55%)<module> (.._handle_fromlist (<frozen importlib._bootstrap>:1078) (4 samples, 7.55%)_handle_fr.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (4 samples, 7.55%)_call_with.._find_and_load (<frozen importlib._bootstrap>:1027) (4 samples, 7.55%)_find_and_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (4 samples, 7.55%)_find_and_.._load_unlocked (<frozen importlib._bootstrap>:688) (4 samples, 7.55%)_load_unlo..exec_module (<frozen importlib._bootstrap_external>:883) (4 samples, 7.55%)exec_modul.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (4 samples, 7.55%)_call_with..<module> (launch/actions/__init__.py:20) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 1.89%)e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_..<module> (launch/actions/execute_local.py:49) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 1.89%)e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_..<module> (launch/event_handlers/__init__.py:17) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:879) (1 samples, 1.89%)e..get_code (<frozen importlib._bootstrap_external>:1015) (1 samples, 1.89%)g.._compile_bytecode (<frozen importlib._bootstrap_external>:672) (1 samples, 1.89%)_..main (ros2cli/cli.py:52) (7 samples, 13.21%)main (ros2cli/cli.py..add_subparsers_on_demand (ros2cli/command/__init__.py:235) (6 samples, 11.32%)add_subparsers_on..get_command_extensions (ros2cli/command/__init__.py:57) (6 samples, 11.32%)get_command_exten..instantiate_extensions (ros2cli/plugin_system.py:40) (6 samples, 11.32%)instantiate_exten..load_entry_points (ros2cli/entry_points.py:91) (6 samples, 11.32%)load_entry_points..load (importlib/metadata/__init__.py:171) (6 samples, 11.32%)load (importlib/m..import_module (importlib/__init__.py:126) (6 samples, 11.32%)import_module (im.._gcd_import (<frozen importlib._bootstrap>:1050) (6 samples, 11.32%)_gcd_import (<fro.._find_and_load (<frozen importlib._bootstrap>:1027) (6 samples, 11.32%)_find_and_load (<.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (6 samples, 11.32%)_find_and_load_un.._load_unlocked (<frozen importlib._bootstrap>:688) (6 samples, 11.32%)_load_unlocked (<..exec_module (<frozen importlib._bootstrap_external>:883) (6 samples, 11.32%)exec_module (<fro.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (6 samples, 11.32%)_call_with_frames..<module> (ros2launch/command/launch.py:30) (5 samples, 9.43%)<module> (ros.._find_and_load (<frozen importlib._bootstrap>:1027) (5 samples, 9.43%)_find_and_loa.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (5 samples, 9.43%)_find_and_loa.._load_unlocked (<frozen importlib._bootstrap>:688) (5 samples, 9.43%)_load_unlocke..exec_module (<frozen importlib._bootstrap_external>:883) (5 samples, 9.43%)exec_module (.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (5 samples, 9.43%)_call_with_fr..<module> (ros2launch/api/__init__.py:20) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 1.89%)e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_..<module> (ros2launch/api/api.py:84) (1 samples, 1.89%)<..get_file_extensions_from_parsers (launch/frontend/parser.py:203) (1 samples, 1.89%)g..load_parser_implementations (launch/frontend/parser.py:94) (1 samples, 1.89%)l..entry_points (importlib/metadata/__init__.py:1021) (1 samples, 1.89%)e..load (importlib/metadata/__init__.py:459) (1 samples, 1.89%)l..<genexpr> (importlib/metadata/__init__.py:1018) (1 samples, 1.89%)<..entry_points (importlib/metadata/__init__.py:631) (1 samples, 1.89%)e..read_text (importlib/metadata/__init__.py:927) (1 samples, 1.89%)r..read_text (pathlib.py:1134) (1 samples, 1.89%)r..open (pathlib.py:1119) (1 samples, 1.89%)o..__process_event (launch/launch_service.py:239) (1 samples, 1.89%)_..handle (launch/event_handlers/on_action_event_base.py:110) (1 samples, 1.89%)h..get_launch_description_from_python_launch_file (launch/launch_description_sources/python_launch_file_utilities.py:62) (1 samples, 1.89%)g..load_python_launch_file_as_module (launch/launch_description_sources/python_launch_file_utilities.py:37) (1 samples, 1.89%)l..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 1.89%)e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_..<module> (ros2_performance.launch.py:3) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:992) (1 samples, 1.89%)_.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 1.89%)e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_..<module> (launch_ros/__init__.py:17) (1 samples, 1.89%)<.._handle_fromlist (<frozen importlib._bootstrap>:1078) (1 samples, 1.89%)_.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 1.89%)e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_..<module> (launch_ros/actions/__init__.py:18) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:883) (1 samples, 1.89%)e.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_..<module> (launch_ros/actions/lifecycle_node.py:32) (1 samples, 1.89%)<.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:992) (1 samples, 1.89%)_.._call_with_frames_removed (<frozen importlib._bootstrap>:241) (1 samples, 1.89%)_.._find_and_load (<frozen importlib._bootstrap>:1027) (1 samples, 1.89%)_.._find_and_load_unlocked (<frozen importlib._bootstrap>:1006) (1 samples, 1.89%)_.._load_unlocked (<frozen importlib._bootstrap>:688) (1 samples, 1.89%)_..exec_module (<frozen importlib._bootstrap_external>:879) (1 samples, 1.89%)e..get_code (<frozen importlib._bootstrap_external>:1015) (1 samples, 1.89%)g.._compile_bytecode (<frozen importlib._bootstrap_external>:672) (1 samples, 1.89%)_..<module> (ros2:33) (15 samples, 28.30%)<module> (ros2:33)main (ros2cli/cli.py:91) (3 samples, 5.66%)main (r..main (ros2launch/command/launch.py:172) (3 samples, 5.66%)main (r..launch_a_launch_file (ros2launch/api/api.py:185) (3 samples, 5.66%)launch_..run (launch/launch_service.py:375) (3 samples, 5.66%)run (la..run_until_complete (asyncio/base_events.py:636) (3 samples, 5.66%)run_unt..run_forever (asyncio/base_events.py:603) (3 samples, 5.66%)run_for.._run_once (asyncio/base_events.py:1909) (3 samples, 5.66%)_run_on.._run (asyncio/events.py:80) (3 samples, 5.66%)_run (a.._process_one_event (launch/launch_service.py:230) (3 samples, 5.66%)_proces..__process_event (launch/launch_service.py:249) (2 samples, 3.77%)__pr..visit_all_entities_and_collect_futures (launch/utilities/visit_all_entities_and_collect_futures_impl.py:45) (2 samples, 3.77%)visi..visit_all_entities_and_collect_futures (launch/utilities/visit_all_entities_and_collect_futures_impl.py:45) (2 samples, 3.77%)visi..visit_all_entities_and_collect_futures (launch/utilities/visit_all_entities_and_collect_futures_impl.py:38) (2 samples, 3.77%)visi..visit (launch/action.py:108) (2 samples, 3.77%)visi..execute (launch/actions/include_launch_description.py:148) (2 samples, 3.77%)exec..get_launch_description (launch/launch_description_source.py:84) (2 samples, 3.77%)get_.._get_launch_description (launch/launch_description_sources/any_launch_description_source.py:53) (2 samples, 3.77%)_get..get_launch_description_from_any_launch_file (launch/launch_description_sources/any_launch_file_utilities.py:54) (2 samples, 3.77%)get_..get_launch_description_from_python_launch_file (launch/launch_description_sources/python_launch_file_utilities.py:68) (1 samples, 1.89%)g..generate_launch_description (ros2_performance.launch.py:10) (1 samples, 1.89%)g..__init__ (launch_ros/actions/node.py:240) (1 samples, 1.89%)_..get_extensions (launch_ros/actions/node.py:569) (1 samples, 1.89%)g..entry_points (importlib/metadata/__init__.py:1021) (1 samples, 1.89%)e..load (importlib/metadata/__init__.py:459) (1 samples, 1.89%)l..<genexpr> (importlib/metadata/__init__.py:1018) (1 samples, 1.89%)<..entry_points (importlib/metadata/__init__.py:631) (1 samples, 1.89%)e..read_text (importlib/metadata/__init__.py:927) (1 samples, 1.89%)r..read_text (pathlib.py:1134) (1 samples, 1.89%)r..open (pathlib.py:1119) (1 samples, 1.89%)o..__fspath__ (pathlib.py:632) (1 samples, 1.89%)_..all (53 samples, 100%)_bootstrap (threading.py:973) (38 samples, 71.70%)_bootstrap (threading.py:973)_bootstrap_inner (threading.py:1016) (38 samples, 71.70%)_bootstrap_inner (threading.py:1016)run (threading.py:953) (38 samples, 71.70%)run (threading.py:953)_do_waitpid (asyncio/unix_events.py:1392) (38 samples, 71.70%)_do_waitpid (asyncio/unix_events.py:1392) \ No newline at end of file diff --git a/src/lib/better_launch/docs/benchmarks/results/pyspy/speedscope-bl.json b/src/lib/better_launch/docs/benchmarks/results/pyspy/speedscope-bl.json new file mode 100644 index 0000000000..536ac73f9b --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/results/pyspy/speedscope-bl.json @@ -0,0 +1 @@ +{"$schema":"https://www.speedscope.app/file-format-schema.json","profiles":[{"type":"sampled","name":"Thread 3004127 \"MainThread\"","unit":"seconds","startValue":0.0,"endValue":0.09,"samples":[[17,5,4,3,8,7,16,5,4,3,8,7,15,5,4,3,8,7,14,5,4,3,8,7,13,5,4,3,8,7,12,10,7,5,4,3,8,7,11,10,7,5,4,3,8,7,9,5,4,3,8,7,6,5,4,3,2,1,0],[17,5,4,3,8,7,16,5,4,3,8,7,21,5,4,3,8,7,20,5,4,3,8,7,19,18],[17,5,4,3,8,7,16,5,4,3,8,7,26,5,4,3,8,7,25,5,4,3,8,7,24,5,4,3,8,7,23,5,4,3,8,7,22,5,4,3,2,1,0],[17,5,4,3,8,7,16,5,4,3,8,7,26,5,4,3,8,7,25,5,4,3,8,7,24,5,4,3,8,7,38,5,4,3,8,7,37,36,35,34,33,32,29,31,29,31,29,31,29,30,29,28,27],[17,5,4,3,8,7,16,5,4,3,8,7,26,5,4,3,8,7,42,5,4,3,8,7,41,5,4,3,8,7,40,5,4,3,8,7,39,10,7,5,4,3,8],[54,53,52,51,50,49,48,47,46,45,44,43],[54,53,52,63,62,61,60,59,58,57,56,55],[54,53,52,63,62,61,60,59,58,57,56,55,64],[54,53,52,63,62,61,60,59,58,57,85,84,83]],"weights":[0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01]},{"type":"sampled","name":"Thread 3004131 \"Thread-1 (_watch_process)\"","unit":"seconds","startValue":0.0,"endValue":0.01,"samples":[[82,81,80,79,78,77,76,75,74,73,72,71,70,69,68,86]],"weights":[0.01]},{"type":"sampled","name":"Thread 3004133 \"Thread-2 (_watch_process)\"","unit":"seconds","startValue":0.0,"endValue":0.01,"samples":[[82,81,80,79,78,77,76,75,74,73,72,71,70,69,68,67,66,65]],"weights":[0.01]}],"shared":{"frames":[{"name":"_compile_bytecode","file":"","line":672,"col":null},{"name":"get_code","file":"","line":1015,"col":null},{"name":"exec_module","file":"","line":879,"col":null},{"name":"_load_unlocked","file":"","line":688,"col":null},{"name":"_find_and_load_unlocked","file":"","line":1006,"col":null},{"name":"_find_and_load","file":"","line":1027,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/socket.py","line":54,"col":null},{"name":"_call_with_frames_removed","file":"","line":241,"col":null},{"name":"exec_module","file":"","line":883,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/multiprocessing/reduction.py","line":16,"col":null},{"name":"_handle_fromlist","file":"","line":1078,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/multiprocessing/context.py","line":6,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/multiprocessing/__init__.py","line":16,"col":null},{"name":"","file":"/opt/ros/humble/local/lib/python3.10/dist-packages/rclpy/executors.py","line":18,"col":null},{"name":"","file":"/opt/ros/humble/local/lib/python3.10/dist-packages/rclpy/node.py","line":58,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/launcher.py","line":16,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/__init__.py","line":9,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/src/better_launch/docs/benchmarks/../../examples/11_performance.launch.py","line":2,"col":null},{"name":"namedtuple","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/collections/__init__.py","line":402,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/platform.py","line":773,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/uuid.py","line":59,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/launcher.py","line":41,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/pprint.py","line":38,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/elements/node.py","line":13,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/elements/group.py","line":1,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/elements/__init__.py","line":1,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/launcher.py","line":45,"col":null},{"name":"append","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/sre_parse.py","line":174,"col":null},{"name":"_parse","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/sre_parse.py","line":619,"col":null},{"name":"_parse_sub","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/sre_parse.py","line":444,"col":null},{"name":"_parse","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/sre_parse.py","line":756,"col":null},{"name":"_parse","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/sre_parse.py","line":842,"col":null},{"name":"parse","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/sre_parse.py","line":955,"col":null},{"name":"compile","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/sre_compile.py","line":788,"col":null},{"name":"_compile","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/re.py","line":303,"col":null},{"name":"compile","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/re.py","line":251,"col":null},{"name":"TextWrapper","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/textwrap.py","line":81,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/textwrap.py","line":17,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/elements/node.py","line":14,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/asyncio/base_events.py","line":43,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/asyncio/__init__.py","line":8,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/elements/ros2_launch_wrapper.py","line":6,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/elements/__init__.py","line":14,"col":null},{"name":"_joinrealpath","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/posixpath.py","line":431,"col":null},{"name":"realpath","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/posixpath.py","line":396,"col":null},{"name":"getmodule","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/inspect.py","line":878,"col":null},{"name":"findsource","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/inspect.py","line":952,"col":null},{"name":"getframeinfo","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/inspect.py","line":1624,"col":null},{"name":"getouterframes","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/inspect.py","line":1650,"col":null},{"name":"stack","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/inspect.py","line":1673,"col":null},{"name":"find_calling_frame","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/utils/introspection.py","line":56,"col":null},{"name":"_launch_this_wrapper","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/wrapper.py","line":94,"col":null},{"name":"decoration_helper","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/wrapper.py","line":69,"col":null},{"name":"launch_this","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/wrapper.py","line":80,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/src/better_launch/docs/benchmarks/../../examples/11_performance.launch.py","line":6,"col":null},{"name":"get_bl_nodes","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/launcher.py","line":315,"col":null},{"name":"spin","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/launcher.py","line":240,"col":null},{"name":"launch_func_wrapper","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/wrapper.py","line":320,"col":null},{"name":"run","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/wrapper.py","line":337,"col":null},{"name":"new_func","file":"/home/dfki.uni-bremen.de/ndahn/.local/lib/python3.10/site-packages/click/decorators.py","line":33,"col":null},{"name":"invoke","file":"/home/dfki.uni-bremen.de/ndahn/.local/lib/python3.10/site-packages/click/core.py","line":787,"col":null},{"name":"invoke","file":"/home/dfki.uni-bremen.de/ndahn/.local/lib/python3.10/site-packages/click/core.py","line":1443,"col":null},{"name":"main","file":"/home/dfki.uni-bremen.de/ndahn/.local/lib/python3.10/site-packages/click/core.py","line":1082,"col":null},{"name":"_launch_this_wrapper","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/wrapper.py","line":352,"col":null},{"name":"get_groups","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/launcher.py","line":286,"col":null},{"name":"formatTime","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/utils/better_logging.py","line":172,"col":null},{"name":"format","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/logging/__init__.py","line":680,"col":null},{"name":"format","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/utils/better_logging.py","line":205,"col":null},{"name":"format","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/logging/__init__.py","line":943,"col":null},{"name":"format","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/ros/handlers.py","line":50,"col":null},{"name":"emit","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/logging/__init__.py","line":1100,"col":null},{"name":"emit","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/logging/__init__.py","line":1218,"col":null},{"name":"emit","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/logging/handlers.py","line":526,"col":null},{"name":"handle","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/logging/__init__.py","line":968,"col":null},{"name":"callHandlers","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/logging/__init__.py","line":1696,"col":null},{"name":"handle","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/logging/__init__.py","line":1634,"col":null},{"name":"_log","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/logging/__init__.py","line":1624,"col":null},{"name":"info","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/logging/__init__.py","line":1477,"col":null},{"name":"_collect_output_bundled","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/elements/node.py","line":279,"col":null},{"name":"_watch_process","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/elements/node.py","line":218,"col":null},{"name":"run","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/threading.py","line":953,"col":null},{"name":"_bootstrap_inner","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/threading.py","line":1016,"col":null},{"name":"_bootstrap","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/threading.py","line":973,"col":null},{"name":"__enter__","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/threading.py","line":265,"col":null},{"name":"done","file":"/home/dfki.uni-bremen.de/ndahn/miniforge3/lib/python3.10/concurrent/futures/_base.py","line":397,"col":null},{"name":"spin","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/launcher.py","line":235,"col":null},{"name":"format","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/lib/python3.10/site-packages/better_launch/utils/better_logging.py","line":201,"col":null}]},"activeProfileIndex":null,"exporter":"py-spy@0.4.0","name":"py-spy profile"} diff --git a/src/lib/better_launch/docs/benchmarks/results/pyspy/speedscope-ros2.json b/src/lib/better_launch/docs/benchmarks/results/pyspy/speedscope-ros2.json new file mode 100644 index 0000000000..e47005146a --- /dev/null +++ b/src/lib/better_launch/docs/benchmarks/results/pyspy/speedscope-ros2.json @@ -0,0 +1 @@ +{"$schema":"https://www.speedscope.app/file-format-schema.json","profiles":[{"type":"sampled","name":"Thread 2956670 \"MainThread\"","unit":"seconds","startValue":0.0,"endValue":0.22,"samples":[[15,14,13,12,11,6,5,4,3,2,10,6,9,2,6,5,4,3,2,8,6,5,4,3,2,7,6,5,4,3,2,1,0],[15,14,13,12,11,6,5,4,3,2,10,6,9,2,6,5,4,3,2,21,6,5,4,3,2,20,6,5,4,3,2,19,6,5,4,18,17,16,2],[15,14,13,12,11,6,5,4,3,2,10,6,9,2,6,5,4,3,2,30,6,5,4,3,2,29,6,5,4,3,2,28,6,27,26,25,24,23,22],[15,14,13,12,11,6,5,4,3,2,10,6,9,2,6,5,4,3,2,33,6,5,4,3,2,32,31],[15,14,13,12,11,6,5,4,3,2,10,6,5,4,3,2,35,6,5,4,3,2,34,6,5,4,18,17,16,2],[15,43,42,41,40,39,38,37,36],[15,43,51,50,49,48,47,46,45,44],[15,43,71,70,69,68,13,12,11,6,5,4,3,2,67,6,5,4,3,2,66,6,9,2,6,5,4,3,2,65,54,2,6,5,4,3,2,64,6,5,4,3,2,63,6,5,4,3,2,62,6,5,4,3,2,61,54,2,6,5,4,3,2,60,6,5,4,3,2,59,6,9,2,6,5,4,3,2,58,6,5,4,3,2,57,6,5,4,3,2,56,6,5,4,3,2,55,54,2,6,5,4,18,53,52],[15,43,71,70,69,68,13,12,11,6,5,4,3,2,67,6,5,4,3,2,66,6,9,2,6,5,4,3,2,65,54,2,6,5,4,3,2,64,6,5,4,3,2,63,6,5,4,3,2,62,6,5,4,3,2,61,54,2,6,5,4,3,2,60,6,5,4,3,2,59,6,9,2,6,5,4,3,2,58,6,5,4,3,2,57,6,5,4,3,2,56,6,5,4,3,2,55,54,2,6,5,4,18,53,52],[15,43,71,70,69,68,13,12,11,6,5,4,3,2,67,6,5,4,3,2,66,6,9,2,6,5,4,3,2,65,54,2,6,5,4,3,2,64,6,5,4,3,2,63,6,5,4,3,2,62,6,5,4,3,2,61,54,2,6,5,4,3,2,60,6,5,4,3,2,59,6,9,2,6,5,4,3,2,58,6,5,4,3,2,57,6,5,4,3,2,56,6,5,4,3,2,75,54,2,6,5,4,3,2,74,54,2,6,5,4,3,2,73,54,2,6,27,72],[15,43,71,70,69,68,13,12,11,6,5,4,3,2,67,6,5,4,3,2,66,6,9,2,6,5,4,3,2,65,54,2,6,5,4,3,2,64,6,5,4,3,2,63,6,5,4,3,2,62,6,5,4,3,2,61,54,2,6,5,4,3,2,60,6,5,4,3,2,59,6,9,2,6,5,4,3,2,84,6,5,4,3,2,83,6,5,4,3,2,82,81,80,79,78,77,76],[15,43,71,70,69,68,13,12,11,6,5,4,3,2,67,6,5,4,3,2,66,6,9,2,6,5,4,3,2,65,54,2,6,5,4,3,2,64,6,5,4,3,2,63,6,5,4,3,2,62,6,5,4,3,2,61,54,2,6,5,4,3,2,60,6,5,4,3,2,59,6,5,4,3,2,87,6,5,4,3,2,86,6,5,4,3,2,85,6,5,4,18,53,52],[15,43,71,70,69,68,13,12,11,6,5,4,3,2,67,6,5,4,3,2,66,6,9,2,6,5,4,3,2,65,54,2,6,5,4,3,2,95,6,5,4,3,2,94,6,5,4,3,2,93,6,5,4,3,2,92,6,27,26,25,24,91,90,89,88],[15,43,71,70,69,68,13,12,11,6,5,4,3,2,67,6,5,4,3,2,102,6,5,4,3,2,101,100,99,40,39,38,37,98,97,96],[15,43,71,70,69,68,13,12,11,6,5,4,3,2,67,6,5,4,3,2,102,6,5,4,3,2,101,100,99,40,104,103],[15,43,108,107,106,69,105,41,40,39,38,37,98,97],[15,132,131,130,129,128,127,126,125,124,123,122,122,121,120,119,118,117,116,115,114,3,2,113,6,9,2,6,5,4,3,2,112,54,2,6,5,4,3,2,111,6,5,4,3,2,110,6,5,4,3,2,109,6,5,4,18,17,16,2],[15,132,131,130,129,128,127,126,125,124,123,122,122,121,120,119,118,117,116,115,114,3,2,113,6,9,2,6,5,4,3,2,112,54,2,6,5,4,3,2,135,6,5,4,3,2,134,6,5,4,3,2,133,6,5,4,18,17,16,2],[15,132,131,130,129,128,127,126,125,124,123,122,122,121,120,119,118,117,116,115,114,3,2,113,6,9,2,6,5,4,3,2,112,54,2,6,5,4,3,2,135,6,5,4,3,2,134,6,5,4,3,2,138,6,5,4,3,2,137,136],[15,132,131,130,129,128,127,126,125,124,123,122,122,121,120,119,118,117,116,115,114,3,2,113,6,9,2,6,5,4,3,2,112,54,2,6,5,4,3,2,135,6,5,4,3,2,134,6,5,4,3,2,139,6,5,4,18,17,16,2],[15,132,131,130,129,128,127,126,125,124,123,122,122,121,120,119,118,117,116,115,114,3,2,113,6,9,2,6,5,4,3,2,112,54,2,6,5,4,3,2,135,6,5,4,3,2,134,6,5,4,3,2,139,6,5,4,18,17,16,2],[15,132,131,130,129,128,127,126,125,124,123,122,122,121,120,119,118,117,116,148,147,146,145,40,39,144,143,142,141,140]],"weights":[0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01]},{"type":"sampled","name":"Thread 2956674 \"waitpid-0\"","unit":"seconds","startValue":0.0,"endValue":0.24,"samples":[[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149]],"weights":[0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01]},{"type":"sampled","name":"Thread 2956676 \"waitpid-1\"","unit":"seconds","startValue":0.0,"endValue":0.1,"samples":[[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149],[152,151,150,149]],"weights":[0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01,0.01]}],"shared":{"frames":[{"name":"namedtuple","file":"/usr/lib/python3.10/collections/__init__.py","line":414,"col":null},{"name":"","file":"/usr/lib/python3.10/inspect.py","line":1641,"col":null},{"name":"_call_with_frames_removed","file":"","line":241,"col":null},{"name":"exec_module","file":"","line":883,"col":null},{"name":"_load_unlocked","file":"","line":688,"col":null},{"name":"_find_and_load_unlocked","file":"","line":1006,"col":null},{"name":"_find_and_load","file":"","line":1027,"col":null},{"name":"","file":"/opt/ros/humble/local/lib/python3.10/dist-packages/rclpy/context.py","line":15,"col":null},{"name":"","file":"/opt/ros/humble/local/lib/python3.10/dist-packages/rclpy/__init__.py","line":47,"col":null},{"name":"_find_and_load_unlocked","file":"","line":992,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/ros2cli/cli.py","line":22,"col":null},{"name":"_gcd_import","file":"","line":1050,"col":null},{"name":"import_module","file":"/usr/lib/python3.10/importlib/__init__.py","line":126,"col":null},{"name":"load","file":"/usr/lib/python3.10/importlib/metadata/__init__.py","line":171,"col":null},{"name":"importlib_load_entry_point","file":"/opt/ros/humble/bin/ros2","line":25,"col":null},{"name":"","file":"/opt/ros/humble/bin/ros2","line":33,"col":null},{"name":"source_to_code","file":"","line":947,"col":null},{"name":"get_code","file":"","line":1018,"col":null},{"name":"exec_module","file":"","line":879,"col":null},{"name":"","file":"/opt/ros/humble/local/lib/python3.10/dist-packages/rcl_interfaces/msg/__init__.py","line":11,"col":null},{"name":"","file":"/opt/ros/humble/local/lib/python3.10/dist-packages/rclpy/parameter.py","line":18,"col":null},{"name":"","file":"/opt/ros/humble/local/lib/python3.10/dist-packages/rclpy/__init__.py","line":48,"col":null},{"name":"_path_join","file":"","line":128,"col":null},{"name":"find_spec","file":"","line":1572,"col":null},{"name":"_get_spec","file":"","line":1411,"col":null},{"name":"find_spec","file":"","line":1439,"col":null},{"name":"_find_spec","file":"","line":945,"col":null},{"name":"_find_and_load_unlocked","file":"","line":1002,"col":null},{"name":"","file":"/opt/ros/humble/local/lib/python3.10/dist-packages/rclpy/guard_condition.py","line":16,"col":null},{"name":"","file":"/opt/ros/humble/local/lib/python3.10/dist-packages/rclpy/signals.py","line":16,"col":null},{"name":"","file":"/opt/ros/humble/local/lib/python3.10/dist-packages/rclpy/__init__.py","line":49,"col":null},{"name":"__new__","file":"/usr/lib/python3.10/enum.py","line":200,"col":null},{"name":"","file":"/opt/ros/humble/local/lib/python3.10/dist-packages/rclpy/task.py","line":28,"col":null},{"name":"","file":"/opt/ros/humble/local/lib/python3.10/dist-packages/rclpy/__init__.py","line":52,"col":null},{"name":"","file":"/opt/ros/humble/local/lib/python3.10/dist-packages/rclpy/subscription.py","line":21,"col":null},{"name":"","file":"/opt/ros/humble/local/lib/python3.10/dist-packages/rclpy/executors.py","line":45,"col":null},{"name":"read_text","file":"/usr/lib/python3.10/importlib/metadata/__init__.py","line":920,"col":null},{"name":"entry_points","file":"/usr/lib/python3.10/importlib/metadata/__init__.py","line":631,"col":null},{"name":"","file":"/usr/lib/python3.10/importlib/metadata/__init__.py","line":1018,"col":null},{"name":"load","file":"/usr/lib/python3.10/importlib/metadata/__init__.py","line":459,"col":null},{"name":"entry_points","file":"/usr/lib/python3.10/importlib/metadata/__init__.py","line":1021,"col":null},{"name":"get_entry_points","file":"/opt/ros/humble/lib/python3.10/site-packages/ros2cli/entry_points.py","line":66,"col":null},{"name":"add_subparsers_on_demand","file":"/opt/ros/humble/lib/python3.10/site-packages/ros2cli/command/__init__.py","line":188,"col":null},{"name":"main","file":"/opt/ros/humble/lib/python3.10/site-packages/ros2cli/cli.py","line":52,"col":null},{"name":"exists","file":"/usr/lib/python3.10/genericpath.py","line":19,"col":null},{"name":"find","file":"/usr/lib/python3.10/gettext.py","line":577,"col":null},{"name":"translation","file":"/usr/lib/python3.10/gettext.py","line":602,"col":null},{"name":"dgettext","file":"/usr/lib/python3.10/gettext.py","line":681,"col":null},{"name":"gettext","file":"/usr/lib/python3.10/gettext.py","line":757,"col":null},{"name":"__init__","file":"/usr/lib/python3.10/argparse.py","line":1748,"col":null},{"name":"add_parser","file":"/usr/lib/python3.10/argparse.py","line":1197,"col":null},{"name":"add_subparsers_on_demand","file":"/opt/ros/humble/lib/python3.10/site-packages/ros2cli/command/__init__.py","line":191,"col":null},{"name":"_compile_bytecode","file":"","line":672,"col":null},{"name":"get_code","file":"","line":1015,"col":null},{"name":"_handle_fromlist","file":"","line":1078,"col":null},{"name":"","file":"/usr/lib/python3.10/asyncio/base_events.py","line":44,"col":null},{"name":"","file":"/usr/lib/python3.10/asyncio/__init__.py","line":8,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/utilities/create_future_impl.py","line":17,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/utilities/__init__.py","line":18,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/frontend/entity.py","line":22,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/frontend/type_utils.py","line":22,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/frontend/__init__.py","line":17,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/logging/__init__.py","line":32,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/actions/declare_launch_argument.py","line":22,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/actions/__init__.py","line":17,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/__init__.py","line":17,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/ros2launch/api/__init__.py","line":17,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/ros2launch/command/launch.py","line":30,"col":null},{"name":"load_entry_points","file":"/opt/ros/humble/lib/python3.10/site-packages/ros2cli/entry_points.py","line":91,"col":null},{"name":"instantiate_extensions","file":"/opt/ros/humble/lib/python3.10/site-packages/ros2cli/plugin_system.py","line":40,"col":null},{"name":"get_command_extensions","file":"/opt/ros/humble/lib/python3.10/site-packages/ros2cli/command/__init__.py","line":57,"col":null},{"name":"add_subparsers_on_demand","file":"/opt/ros/humble/lib/python3.10/site-packages/ros2cli/command/__init__.py","line":235,"col":null},{"name":"_find_spec","file":"","line":937,"col":null},{"name":"","file":"/usr/lib/python3.10/asyncio/locks.py","line":8,"col":null},{"name":"","file":"/usr/lib/python3.10/asyncio/staggered.py","line":10,"col":null},{"name":"","file":"/usr/lib/python3.10/asyncio/base_events.py","line":45,"col":null},{"name":"_parse","file":"/usr/lib/python3.10/sre_parse.py","line":718,"col":null},{"name":"_parse_sub","file":"/usr/lib/python3.10/sre_parse.py","line":444,"col":null},{"name":"parse","file":"/usr/lib/python3.10/sre_parse.py","line":955,"col":null},{"name":"compile","file":"/usr/lib/python3.10/sre_compile.py","line":788,"col":null},{"name":"_compile","file":"/usr/lib/python3.10/re.py","line":303,"col":null},{"name":"compile","file":"/usr/lib/python3.10/re.py","line":251,"col":null},{"name":"","file":"/usr/lib/python3.10/platform.py","line":1255,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/utilities/signal_management.py","line":20,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/utilities/__init__.py","line":22,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/.local/lib/python3.10/site-packages/yaml/loader.py","line":6,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/.local/lib/python3.10/site-packages/yaml/__init__.py","line":8,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/utilities/type_utils.py","line":29,"col":null},{"name":"_path_stat","file":"","line":147,"col":null},{"name":"_path_is_mode_type","file":"","line":153,"col":null},{"name":"_path_isfile","file":"","line":161,"col":null},{"name":"find_spec","file":"","line":1577,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/events/__init__.py","line":22,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/actions/timer_action.py","line":36,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/actions/execute_local.py","line":42,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/actions/__init__.py","line":20,"col":null},{"name":"open","file":"/usr/lib/python3.10/pathlib.py","line":1119,"col":null},{"name":"read_text","file":"/usr/lib/python3.10/pathlib.py","line":1134,"col":null},{"name":"read_text","file":"/usr/lib/python3.10/importlib/metadata/__init__.py","line":927,"col":null},{"name":"load_parser_implementations","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/frontend/parser.py","line":94,"col":null},{"name":"get_file_extensions_from_parsers","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/frontend/parser.py","line":203,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/ros2launch/api/api.py","line":84,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/ros2launch/api/__init__.py","line":20,"col":null},{"name":"","file":"/usr/lib/python3.10/importlib/metadata/__init__.py","line":461,"col":null},{"name":"load","file":"/usr/lib/python3.10/importlib/metadata/__init__.py","line":461,"col":null},{"name":"load_entry_points","file":"/opt/ros/humble/lib/python3.10/site-packages/ros2cli/entry_points.py","line":87,"col":null},{"name":"get_option_extensions","file":"/opt/ros/humble/lib/python3.10/site-packages/ros2launch/option/__init__.py","line":76,"col":null},{"name":"add_arguments","file":"/opt/ros/humble/lib/python3.10/site-packages/ros2launch/command/launch.py","line":121,"col":null},{"name":"add_subparsers_on_demand","file":"/opt/ros/humble/lib/python3.10/site-packages/ros2cli/command/__init__.py","line":250,"col":null},{"name":"","file":"/opt/ros/humble/local/lib/python3.10/dist-packages/lifecycle_msgs/srv/__init__.py","line":3,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch_ros/actions/lifecycle_node.py","line":29,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch_ros/actions/__init__.py","line":18,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch_ros/__init__.py","line":17,"col":null},{"name":"","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/share/better_launch/examples/ros2_performance.launch.py","line":3,"col":null},{"name":"load_python_launch_file_as_module","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/launch_description_sources/python_launch_file_utilities.py","line":37,"col":null},{"name":"get_launch_description_from_python_launch_file","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/launch_description_sources/python_launch_file_utilities.py","line":62,"col":null},{"name":"get_launch_description_from_any_launch_file","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/launch_description_sources/any_launch_file_utilities.py","line":54,"col":null},{"name":"_get_launch_description","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/launch_description_sources/any_launch_description_source.py","line":53,"col":null},{"name":"get_launch_description","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/launch_description_source.py","line":84,"col":null},{"name":"execute","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/actions/include_launch_description.py","line":148,"col":null},{"name":"visit","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/action.py","line":108,"col":null},{"name":"visit_all_entities_and_collect_futures","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/utilities/visit_all_entities_and_collect_futures_impl.py","line":38,"col":null},{"name":"visit_all_entities_and_collect_futures","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/utilities/visit_all_entities_and_collect_futures_impl.py","line":45,"col":null},{"name":"__process_event","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/launch_service.py","line":249,"col":null},{"name":"_process_one_event","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/launch_service.py","line":230,"col":null},{"name":"_run","file":"/usr/lib/python3.10/asyncio/events.py","line":80,"col":null},{"name":"_run_once","file":"/usr/lib/python3.10/asyncio/base_events.py","line":1909,"col":null},{"name":"run_forever","file":"/usr/lib/python3.10/asyncio/base_events.py","line":603,"col":null},{"name":"run_until_complete","file":"/usr/lib/python3.10/asyncio/base_events.py","line":636,"col":null},{"name":"run","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/launch_service.py","line":375,"col":null},{"name":"launch_a_launch_file","file":"/opt/ros/humble/lib/python3.10/site-packages/ros2launch/api/api.py","line":185,"col":null},{"name":"main","file":"/opt/ros/humble/lib/python3.10/site-packages/ros2launch/command/launch.py","line":172,"col":null},{"name":"main","file":"/opt/ros/humble/lib/python3.10/site-packages/ros2cli/cli.py","line":91,"col":null},{"name":"","file":"/opt/ros/humble/local/lib/python3.10/dist-packages/composition_interfaces/srv/__init__.py","line":1,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch_ros/actions/load_composable_nodes.py","line":25,"col":null},{"name":"","file":"/opt/ros/humble/lib/python3.10/site-packages/launch_ros/actions/__init__.py","line":19,"col":null},{"name":"LoadNode_Response","file":"/opt/ros/humble/local/lib/python3.10/dist-packages/composition_interfaces/srv/_load_node.py","line":366,"col":null},{"name":"","file":"/opt/ros/humble/local/lib/python3.10/dist-packages/composition_interfaces/srv/_load_node.py","line":348,"col":null},{"name":"","file":"/opt/ros/humble/local/lib/python3.10/dist-packages/composition_interfaces/srv/__init__.py","line":2,"col":null},{"name":"","file":"/opt/ros/humble/local/lib/python3.10/dist-packages/composition_interfaces/srv/__init__.py","line":3,"col":null},{"name":"splitext","file":"/usr/lib/python3.10/posixpath.py","line":118,"col":null},{"name":"_name_from_stem","file":"/usr/lib/python3.10/importlib/metadata/__init__.py","line":956,"col":null},{"name":"_normalized_name","file":"/usr/lib/python3.10/importlib/metadata/__init__.py","line":942,"col":null},{"name":"unique_everseen","file":"/usr/lib/python3.10/importlib/metadata/_itertools.py","line":16,"col":null},{"name":"","file":"/usr/lib/python3.10/importlib/metadata/__init__.py","line":1019,"col":null},{"name":"get_extensions","file":"/opt/ros/humble/lib/python3.10/site-packages/launch_ros/actions/node.py","line":569,"col":null},{"name":"__init__","file":"/opt/ros/humble/lib/python3.10/site-packages/launch_ros/actions/node.py","line":240,"col":null},{"name":"generate_launch_description","file":"/home/dfki.uni-bremen.de/ndahn/devel/workspaces/my_workspace/test_ws/install/better_launch/share/better_launch/examples/ros2_performance.launch.py","line":10,"col":null},{"name":"get_launch_description_from_python_launch_file","file":"/opt/ros/humble/lib/python3.10/site-packages/launch/launch_description_sources/python_launch_file_utilities.py","line":68,"col":null},{"name":"_do_waitpid","file":"/usr/lib/python3.10/asyncio/unix_events.py","line":1392,"col":null},{"name":"run","file":"/usr/lib/python3.10/threading.py","line":953,"col":null},{"name":"_bootstrap_inner","file":"/usr/lib/python3.10/threading.py","line":1016,"col":null},{"name":"_bootstrap","file":"/usr/lib/python3.10/threading.py","line":973,"col":null}]},"activeProfileIndex":null,"exporter":"py-spy@0.4.0","name":"py-spy profile"} diff --git a/src/lib/better_launch/docs/howto/python.md b/src/lib/better_launch/docs/howto/python.md new file mode 100644 index 0000000000..1d17b9b7ca --- /dev/null +++ b/src/lib/better_launch/docs/howto/python.md @@ -0,0 +1,115 @@ +# :snake: Python Launchfiles + +???+ tip + + *better_launch* comes with many example launchfiles that will cover all the essentials and some advanced use cases. They can be found in the [repo](https://github.com/dfki-ric/better_launch/tree/main/examples/python). This chapter will serve as a more general introduction. + +## Header +A *better_launch* python launchfile will usually start with the basic imports followed by your decorated main launch function: + +```python +from better_launch import BetterLaunch, launch_this + +@launch_this +def my_launch_function(): + bl = BetterLaunch() + ... +``` + +The [@launch_this](../../reference/better_launch/wrapper/#better_launch.wrapper.launch_this) decorator will start the launch process and run your function, or run it in an already running launch process in case the launchfile got included. It will also expose a `generate_launch_description` function in case the launchfile got included or run from ROS2. In this case, your launch function will be wrapped in an `OpaqueCoroutine`, so *better_launch* will still be used for execution. + +You may name the decorated function however you like, its name serves no purpose beyond self-documentation. Your function should start by creating an instance of [BetterLaunch](../../reference/better_launch/launcher/#better_launch.launcher.BetterLaunch). This instance will provide you access to all of the launch functions, like creating nodes, calling services, etc. It is also a singleton, so trying to instantiate it multiple times will always return the same instance. + +## Launch Arguments +To add launch arguments, simply add them as function arguments. You should provide a type hint and/or default value so that passed arguments are resolved to the correct type on execution. If no default is provided the argument will be required. + +```python +from better_launch import BetterLaunch, launch_this + +@launch_this +def my_launch_function(i_like_haikus: bool): + bl = BetterLaunch() + print(i_like_haikus, type(i_like_haikus)) + ... +``` + +If you provide a docstring to your launch function it will be used to create a help text and argument descriptions for your launchfile. *better_launch* uses numpy style docstrings, as I think it is the most concise one, but all common docstring formats are supported. + +```python +@launch_this +def my_launch_function(i_like_haikus: bool): + """Launchfiles sprawl in knots / + One clean cut to banish chaos / + Silence, then motion. + + Parameters + ---------- + i_like_haikus : bool + I mean, who doesn't? + """ + bl = BetterLaunch() + print(i_like_haikus, type(i_like_haikus)) + ... +``` + +```bash +❯ bl ./haiku.launch.toml --help +Usage: bl ./haiku.toml [OPTIONS] + + Launchfiles sprawl in knots / One clean cut to banish chaos / Silence, then motion + +Options: + --i_like_haikus BOOLEAN I mean, who doesn't? [default: True] +``` + +## Doing Things +Most launch actions will be executed through the [BetterLaunch](../../reference/better_launch/launcher/#better_launch.launcher.BetterLaunch) instance you acquire in the beginning. One of the most common launch patterns is loading a config file, then passing it to a node. + +```python +from better_launch import BetterLaunch, launch_this + +@launch_this +def my_launch_function(): + bl = BetterLaunch() + + params = bl.load_params("my_package", "my_config.yaml") + with bl.group("my_namespace"): + my_node = bl.node("my_package", "my_node.py", params=params) + print(my_node.get_subscribed_topics()) +``` + +Simple as that! Notice how we also acquired a reference to the node we started? This is possible because *better_launch* will execute all actions synchronously. Once the `bl.node` call returns the node's process is already underway. This allows you to do some advanced actions that are not possible in ROS2 launchfiles: + +- Query a node's live parameters. +- Check if it is a lifecycle node and change its lifecycle state. +- Terminate or restart nodes. +- List its topics, services, etc. +- Get a node's process ID. +- etc. + +???+ note + + ROS2 (mildly) pushes the philosophy that the launch logic should be kept separate from runtime logic. It is up to you to decide what's appropriate for your launchfile/system. + +## Common Helpers +Loading configs and running nodes are general enough to be considered building blocks. There are also some common patterns that build upon these Legos. These are collected in the [convenience](../../reference/better_launch/convenience/) and [gazebo](../../reference/better_launch/gazebo/) modules. + +```python +from better_launch import BetterLaunch, launch_this, convenience + +@launch_this +def my_launch_function(): + # Still need to create the instance first + bl = BetterLaunch() + + convenience.joint_state_publisher() + convenience.record_topics() +``` + +## Launching Launchfiles +Astute readers may have noticed that the above example launchfile was run using the `bl` command. While you can in fact run *better_launch* launchfiles using the good ol' `ros2 launch`, this will deprive you of some quality of life features: + +1. `bl` offers auto-completion for launch arguments. +2. It provides auto-completion even for regular ROS2 launchfiles. +3. It resolves and starts all launchfiles much faster than `ros2 launch`. +4. *better_launch* becomes the main launch process instead of ROS2's `LaunchService`, ensuring clean termination. diff --git a/src/lib/better_launch/docs/howto/settings.md b/src/lib/better_launch/docs/howto/settings.md new file mode 100644 index 0000000000..2c18b566d6 --- /dev/null +++ b/src/lib/better_launch/docs/howto/settings.md @@ -0,0 +1,64 @@ +# :gear: Settings + +## Launch Options + +*better_launch* has a few settings that can be set from launchfiles or externally. + +Every option listed here can be set in the launchfile, as an environment variable, or passed as a command line argument. The priority order is always `CLI > env > launchfile`. Their names slightly change depending on where you define them: + +- In python launchfiles, pass them in lowercase to the [@launch_this](../../reference/better_launch/wrapper/#better_launch.wrapper.launch_this) decorator (e.g. `ui=True`). +- For [TOML lanuchfiles](../howto/toml.md), define them as global launch arguments and add a `bl_` prefix (so `ui` becomes `bl_ui`). +- As environment variables they become uppercase and get a `BL_` prefix (so `ui` becomes `BL_UI`). +- On the command line they get a `--bl-` prefix and underscores become dashes (so `ui` becomes `--bl-ui`). + +??? example "ui *(bool)*" + + Enables or disables the [terminal UI](../howto/tui.md). + +??? example "colormode *(string)*" + + How to apply colors to terminal logging output. + + - default: uniform highlight for node names, colored severity levels. + - severity: colored severity levels. + - source: colored node names. + - none: no colors. + - rainbow: colored node names, colored severity levels. + +??? example "print_limit *(int)*" + + Limits the character length of messages printed to the terminal. No limit if <= 0. + +??? example "screen_log_level *(int)*" + + Only print messages to the terminal if they have at least this severity (based on python's logging module). , as defined in python's logging module (10 = DEBUG, 20 = INFO, etc). + +??? example "file_log_level *(int)*" + + Only write messages to log files if they have at least this severity (see above). + +??? example "screen_log_format *(string)*" + + Overrides the format for messages logged to the terminal. Check the [PrettyLogFormatter](../../reference/better_launch/utils/better_logging/) for valid syntax. + +??? example "file_log_format *(string)*" + + Overrides the format for messages logged to log files, following the same format as the screen log format. + +??? example "use_sim_time *(bool)*" + + Changes the default `use_sim_time` setting of the root group. All nodes will use this setting unless one of their ancestor groups makes a different override. Note that this parameter does not have the `bl-` prefix on the command line (env is as usual) - it felt more intuitive this way. + + +## TUI Shortcuts +On some systems the default TUI shortcuts may be inconvenient, e.g. due to conflicts with system or terminal shortcuts. These can be changed by placing a special string in the `BL_TUI_KEYBINDS` environment variable. For example, to change the *Nodes* action to `ctrl-n` and the *Log Level* action to `ctrl-l` you would specify + +```bash +export BL_TUI_KEYBINDS="nodes: c-n; loglevel: c-l" +``` + +The syntax is always `:` separated by `;`. Valid actions are `exit`, `mute`, `nodes`, `loglevel`, `cancel`, `enter`, `next`, `previous`. For valid key specifiers see the [prompt_toolkit documentation](https://python-prompt-toolkit.readthedocs.io/en/stable/pages/advanced_topics/key_bindings.html). + +???+ warning + + Take special note of how the quirks around `ctrl-s`, `ctrl-q`, and the Alt/Meta/Option keys. The former cannot be bound in most terminals without additional setup, while combinations with the meta key are treated as two separate key presses. This too is documented on the prompt_toolkit website. diff --git a/src/lib/better_launch/docs/howto/toml.md b/src/lib/better_launch/docs/howto/toml.md new file mode 100644 index 0000000000..fec5bf5e8a --- /dev/null +++ b/src/lib/better_launch/docs/howto/toml.md @@ -0,0 +1,115 @@ +# :t_rex: TOML Launchfiles + +The discussions over at ros discourse revealed that there was a need for launchfiles that are not code-based but can be parsed offline. TOML launchfiles provide exactly that: the full power of *better_launch* while limiting the complexity that would be achievable through python. + +???+ tip + + This page will give you a general overview. Have a look at the [examples](https://github.com/dfki-ric/better_launch/tree/main/examples/toml) for more details! + +???+ example "Feedback welcome!" + + The TOML format is an entirely new way of writing launchfiles. While I have faith in the general approach, it may not quite meet the needs yet. [Feedback](https://github.com/dfki-ric/better_launch/issues) is very much welcome! + +## Call Tables +In TOML launchfiles most tables are so-called *call tables*. These are dictionaries with a `func` key referring to one of the public [BetterLaunch](../../reference/better_launch/launcher/#better_launch.launcher.BetterLaunch) member functions (the [convenience](../../reference/better_launch/launcher/#better_launch.convenience) and [gazebo](../../reference/better_launch/launcher/#better_launch.gazebo) modules are also supported). Most other attributes are treated as keyword arguments to that function. Just like with python launchfiles, call tables are executed in order of appearance, and their return values are stored under the table’s name. + +For example, the following call table will call [bl.find](../../reference/better_launch/launcher/#better_launch.launcher.BetterLaunch.find), pass `better_launch` as the package and `cube.sdf` as the filename, then store the returned value under `a_simple_cube`. + +```toml +[a_simple_cube] +func = "find" +package = "better_launch" +filename = "cube.sdf" +``` + +???+ warning + + Every call table *must* have a unique name even if the call doesn't have a result or the result is not used! + +## Substitutions +Substitutions take heavy inspiration from those found in ROS1 and should be familiar to many. However, as the TOML launchfile format is much more powerful, only the following substitutions were +deemed necessary for now: + + - `${}`: value of launch argument or call table `` + - `${param

}`: query node `` for its ROS2 parameter `

` + - `${env }`: environment variable `` (default ``) + - `${eval }`: evaluate Python expression `` (disabled by default) + +Substitutions may be nested (inner ones resolve first). The following call table will log the path of the cube we previously located: + +```toml +[print_cube] +func = "log" +severity = "info" +message = "A simple cube can be found at ${a_simple_cube}" +``` + +???+ important + + `eval` poses a security risk and is therefore disabled by default. This can be changed by creating a global launch argument `bl_eval_mode` and setting it to either `full` (regular `eval`) or `literal_eval` (only parses literal values). + +## Conditions +Call tables can be made conditional by adding an `if` or `unless` parameter. The table will only run if this value evaluates to true (or false for `unless`) according to python truthiness. Be aware that non-empty strings are also considered true. If a call table doesn't run or has no return, `None` will be stored under its name. + +## Launch Arguments +Launch arguments can be defined as global parameters before the first table. Their values can be used in substitutions just like any call table result. In addition, you may set the `bl_allow_kwargs` so arbitrary launch arguments can be passed to the launchfile (i.e. for nodes that consume the additional inputs). + +In order to support required launch arguments, *better_launch* makes a small extension to TOML: when defining your launch arguments, instead of assigning them a value you may also assign them one of python's primitive types *without quotes* (e.g. `my_arg = bool`). The launchfile will then require this argument to be passed and handle it as the specified type. + +## Namespaces & Components +When using functions like [bl.group](../../reference/better_launch/launcher/#better_launch.launcher.BetterLaunch.group) and [bl.compose](../../reference/better_launch/launcher/#better_launch.launcher.BetterLaunch.compose) that create a python context, you have to add the elements belonging to them as *children* using the following syntax: + +```toml +[my_composer] +func = "compose" + +[my_composer.children.talker] +func = "component" +package = "composition" +plugin = "composition::Talker" +``` + +You may also use TOML table arrays. In this case the children will get assigned a key according to their index (order of appearance). These two patterns cannot be mixed. + +```toml +# key will be my_composer.children.0 +[[my_composer.children]] +... + +# key will be my_composer.children.1 +[[my_composer.children]] +... +``` + +## Convenience & Gazebo +By default, call tables will lookup the function to run on the [BetterLaunch](../../reference/better_launch/launcher/#better_launch.launcher.BetterLaunch) instance. To run functions from the [convenience](../../reference/better_launch/convenience/) or [gazebo](../../reference/better_launch/gazebo/) modules, you may specify a `context` parameter and pass it either `convenience` or `gazebo`. This *cannot* be used to run code from arbitrary modules - only these values are supported. + +For example, the following call table will start a rosbag recording: + +```toml +[start_recording] +context = "convenience" +func = "record_topics" +``` + +# Comments & Docstrings +While TOML has support for comments (using `#`), existing parsers either don't preserve them, make no association between comments and values, or simply don't work for all cases. The TOML parser in *better_launch* treats any block of comments immediately preceding a key-value pair as belonging to it. This is relevant for any launch arguments you define, as their associated comments will be turned into help text for the command line. In addition, a comment block at the top of the file followed by a blank line will be treated as the launchfile's docstring. + +```toml +# Launchfiles sprawl in knots / +# One clean cut to banish chaos / +# Silence, then motion + +# I mean, who doesn't? +i_like_haikus = True +``` + +```bash +❯ bl ./haiku.launch.toml --help +Usage: bl ./haiku.toml [OPTIONS] + + Launchfiles sprawl in knots / One clean cut to banish chaos / Silence, then motion + +Options: + --i_like_haikus BOOLEAN I mean, who doesn't? [default: True] +``` diff --git a/src/lib/better_launch/docs/howto/tui.md b/src/lib/better_launch/docs/howto/tui.md new file mode 100644 index 0000000000..51fbb10283 --- /dev/null +++ b/src/lib/better_launch/docs/howto/tui.md @@ -0,0 +1,42 @@ +# :pager: The TUI + +*better_launch* comes with a sneaky, unobstrusive TUI (terminal user interface) based on [prompt_toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit), which will hover below the log output. You can start it by either passing `ui=True` to the `launch_this` wrapper, or by adding `--bl-ui true` on the command line. Use *\* to switch between menu items. + +```bash +# Run this line to see it in action! +bl better_launch 02_ui.launch.py +``` + +![TUI](../assets/images/tui_large.png) + +See the single line at the bottom of the terminal? That's the TUI, and it will never take up more than 3 lines! Despite its simplicity, the TUI allows you a comfortable degree of control over all nodes managed by the *better_launch* process it is running in: + +??? quote "list nodes and their status" + + ![TUI](../assets/images/tui_search.png) + +??? quote "control nodes (start, stop, lifecycle, ...)" + + ![TUI](../assets/images/tui_node_ctrl.png) + +??? quote "change a node's log level" + + ![TUI](../assets/images/tui_loglevel.png) + +??? quote "see a node's services and topics" + + ![TUI](../assets/images/tui_node_info.png) + +--- + +???+ tip + + It is possible to change the key bindings. The [settings page](settings.md#tui-shortcuts) contains further details. + +## Foreign nodes + +The TUI is also able to manage nodes started from different shells and processes, even if they have been started by ROS2 or other means. To do so, pass the `manage_foreign_nodes` flag to the wrapper or command line. Be aware though that this will not capture their output - to get their output you will have to use the *takeover* action from the TUI, which will *terminate and restart* the node process with the original arguments. + +???+ note + + Foreign node processes are identified by having one of the following parameters in their arguments: `__ns`, `__name`, `__node`, `--ros-args`. This is always true for nodes started from launch files, but fails when they were started via `ros2 run` or other means. As far as I'm aware, there is no better way right now. diff --git a/src/lib/better_launch/docs/index.md b/src/lib/better_launch/docs/index.md new file mode 100644 index 0000000000..4cee564426 --- /dev/null +++ b/src/lib/better_launch/docs/index.md @@ -0,0 +1,9 @@ +--- +template: home.html +title: better_launch +social: + cards_layout_options: + title: A better launch system for ROS2 +--- + +Welcome to better_launch \ No newline at end of file diff --git a/src/lib/better_launch/docs/installation/installation.md b/src/lib/better_launch/docs/installation/installation.md new file mode 100644 index 0000000000..4f1b4745b1 --- /dev/null +++ b/src/lib/better_launch/docs/installation/installation.md @@ -0,0 +1,63 @@ +# :inbox_tray: Installation +I'm working on getting a proper distro package up and running, but it's a surprisingly non-fun process. Until then you may follow the steps below! + +???+ tip + + *better_launch* is a regular ROS2 package, which means you can install it in your workspace and then use it in all launch files within that workspace. + +ROS2 is slowly [moving towards pixi](https://docs.ros.org/en/kilted/Installation/Windows-Install-Binary.html) as the main python3 environment, but I have not tested it yet. However, by now all the dependencies have been added into rosdep, so the following should get you up and running: + +```bash +# Get better_launch into your workspace src folder +cd /src +git clone https://github.com/dfki-ric/better_launch.git +``` + +```bash +# Install the dependencies +sudo apt update +rosdep update +rosdep install --from-paths src --ignore-src -y +``` + +??? example "Using a python venv" + + If you prefer a python virtual environment instead of installing system packages, here is a setup that worked for us in the past: + + ```bash + # Install some prerequisites + sudo apt install python3-pip python3-venv + + # Create a virtual environment for your workspace + cd your/ros2/workspace/ + mkdir venv + python3 -m venv ./venv --system-site-packages --symlinks + touch venv/COLCON_IGNORE + + # Activate the venv + source ./venv/bin/activate + + # Activate your ROS2 workspace + source ./install/setup.bash + + # Install the dependencies into your venv + pip install -r path/to/better_launch/requirements.txt + ``` + +Once all the dependencies are installed you should build your workspace. + +```bash +# Build the better_launch package +cd +colcon build --packages-up-to better_launch +source install/setup.bash +``` + +```bash +# Verify installation +bl --help +``` + +??? example "The devel branch" + + If you are the experimental type, *better_launch* also has a `devel` branch where I merge new features for testing. I try to keep it functional (since I'm using it myself), although there might be the occasional hiccup. diff --git a/src/lib/better_launch/docs/overrides/assets/stylesheets/apidoc.css b/src/lib/better_launch/docs/overrides/assets/stylesheets/apidoc.css new file mode 100644 index 0000000000..11b8ca8ceb --- /dev/null +++ b/src/lib/better_launch/docs/overrides/assets/stylesheets/apidoc.css @@ -0,0 +1,46 @@ +/* Function blocks */ +.doc-function { + border-left: 4px solid var(--md-accent-fg-color); + padding: 0.8em 1em; + margin: 1.2em 0; + background: var(--md-code-bg-color); + border-radius: 6px; +} + +/* Function signature */ +.doc-function > .doc-heading { + font-weight: 600; + font-size: 0.95em; + margin-bottom: 0.6em; +} + +/* Parameters section */ +.doc-param { + margin: 0.4em 0; + padding-left: 0.6em; + border-left: 2px solid rgba(0, 0, 0, 0.15); +} + +/* Parameter name */ +.doc-param-name { + font-weight: 600; + color: var(--md-accent-fg-color); +} + +/* Parameter type */ +.doc-param-type { + font-family: var(--md-code-font-family); + font-size: 0.85em; + opacity: 0.85; +} + +/* Parameter description */ +.doc-param-doc { + margin-top: 0.2em; + margin-left: 0.2em; +} + +/* Improve spacing between parameters */ +.doc-params { + margin-top: 0.6em; +} diff --git a/src/lib/better_launch/docs/overrides/assets/stylesheets/custom.css b/src/lib/better_launch/docs/overrides/assets/stylesheets/custom.css new file mode 100644 index 0000000000..6cdddf98db --- /dev/null +++ b/src/lib/better_launch/docs/overrides/assets/stylesheets/custom.css @@ -0,0 +1,26 @@ +/* Customizations for the entire website */ +.md-header { + background: hsl(245, 41%, 17%);; +} +.md-tabs { + background: hsl(245, 41%, 17%);; +} + +.md-footer { + position: relative; +} + +.footer-logo-container { + position: absolute; + left: 50%; + transform: translate(-50%, 0%); + z-index: 10; + margin-top: .4rem; +} + +#footer-logo { + height: 50px; + width: auto; + display: block; + padding: 0 1rem; +} \ No newline at end of file diff --git a/src/lib/better_launch/docs/overrides/assets/stylesheets/hero.css b/src/lib/better_launch/docs/overrides/assets/stylesheets/hero.css new file mode 100644 index 0000000000..f16a1bbcde --- /dev/null +++ b/src/lib/better_launch/docs/overrides/assets/stylesheets/hero.css @@ -0,0 +1,44 @@ +/* Hero landing page */ + +.mdx-hero { + margin: 0; + color: var(--md-primary-bg-color); +} + +.mdx-hero h1 { + font-weight: 700; + color: currentcolor; + margin-bottom: 1rem; +} + +.mdx-hero__content { + position: absolute; + left: 4rem; + top: 64vh; + line-height: 1.2; +} + +.mdx-hero__image img { + object-fit: cover; + width: 100%; +} + +.mdx-hero .md-button { + margin-top: 1rem; + margin-right: 10px; + color: var(--md-primary-bg-color); +} + +.mdx-hero .md-button:is(:focus, :hover) { + color: var(--md-primary-bg-color); + background-color: darkslateblue; + border-color: darkslateblue; +} + +.mdx-hero .md-button--primary { + color: darkslateblue; + background-color: var(--md-primary-bg-color); + border-color: var(--md-primary-bg-color); +} + +/*# sourceMappingURL=hero.css.map */ diff --git a/src/lib/better_launch/docs/overrides/home.html b/src/lib/better_launch/docs/overrides/home.html new file mode 100644 index 0000000000..f8dcb988b5 --- /dev/null +++ b/src/lib/better_launch/docs/overrides/home.html @@ -0,0 +1,99 @@ +{% extends "main.html" %} + + +{% block tabs %} + {{ super() }} + + + + + +

+ +
+
+ + +
+ Created by Corrvyd, https://artistree.io/corrvyd <3 + +
+ + +
+

better_launch

+

A better replacement for the ROS2 launch system:

+

intuitive, simple, memorable.

+ + About + +
+
+
+
+{% endblock %} + + +{% block content %}{% endblock %} + + +{% block footer %}{% endblock %} diff --git a/src/lib/better_launch/docs/overrides/main.html b/src/lib/better_launch/docs/overrides/main.html new file mode 100644 index 0000000000..2407b75116 --- /dev/null +++ b/src/lib/better_launch/docs/overrides/main.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + + +{% block extrahead %} + + + + +{% endblock %} + + +{% block announce %} + 1.6.0 is out and brings a flurry of nice features! +{% endblock %} diff --git a/src/lib/better_launch/docs/overrides/partials/footer.html b/src/lib/better_launch/docs/overrides/partials/footer.html new file mode 100644 index 0000000000..8b06e9e5e8 --- /dev/null +++ b/src/lib/better_launch/docs/overrides/partials/footer.html @@ -0,0 +1,104 @@ + + + + diff --git a/src/lib/better_launch/docs/paper.bib b/src/lib/better_launch/docs/paper.bib new file mode 100644 index 0000000000..5d1dd08c09 --- /dev/null +++ b/src/lib/better_launch/docs/paper.bib @@ -0,0 +1,80 @@ +@misc{ros2_launch_design, + author = {William Woodall}, + title = {ROS 2 Launch System}, + year = {2019}, + publisher = {Open Source Robotics Foundation}, + journal = {ROS 2 Design}, + url = {https://design.ros2.org/articles/roslaunch.html} +} +@misc{launchmap, + author = {Sakshay Mahna and da Costa Silva, Luana Priscila}, + title = {LaunchMap – Visualize Your ROS 2 Launch Files}, + year = {2025}, + publisher = {GitHub}, + journal = {GitHub repository}, + url = {https://github.com/Kodo-Robotics/launchmap} +} +@misc{simple_launch, + author = {Olivier Kermorgant and Haowei Wen and Viktor Pocedulić}, + title = {{simple_launch}}, + year = {2020}, + publisher = {GitHub}, + journal = {GitHub repository}, + url = {https://github.com/oKermorgant/simple_launch} +} +@misc{launch_generator, + author = {Tatsuro Sakaguchi}, + title = {{launch-generator}}, + year = {2023}, + publisher = {GitHub}, + journal = {GitHub repository}, + url = {https://github.com/Tacha-S/launch_generator} +} +@misc{generate_parameter_library, + author = {PickNikRobotics}, + title = {{generate_parameter_library}}, + year = {2022}, + publisher = {GitHub}, + journal = {GitHub repository}, + url = {https://github.com/PickNikRobotics/generate_parameter_library} +} +@misc{rosmon, + author = {Max Schwarz and others}, + title = {{rosmon}}, + year = {2015}, + publisher = {GitHub}, + journal = {GitHub repository}, + url = {https://github.com/xqms/rosmon} +} +@misc{ros2_substitution_example, + author = {{Open Robotics}}, + title = {Using Substitutions}, + year = {2025}, + publisher = {Open Robotics}, + journal = {ROS 2 Tutorials}, + url = {https://docs.ros.org/en/jazzy/Tutorials/Intermediate/Launch/Using-Substitutions.html} +} +@misc{py-spy, + author = {Ben Frederickson}, + title = {{py-spy}}, + year = {2025}, + publisher = {Github}, + journal = {Github repository}, + url = {https://github.com/benfred/py-spy} +} +@misc{memray, + author = {{Bloomberg Engineering}}, + title = {memray}, + year = {2025}, + publisher = {Github}, + journal = {Github repository}, + url = {https://github.com/bloomberg/memray} +} +@misc{psutil, + author = {Giampaolo Rodola}, + title = {{psutil}}, + year = {2025}, + publisher = {Github}, + journal = {Github repository}, + url = {https://github.com/giampaolo/psutil} +} diff --git a/src/lib/better_launch/docs/paper.md b/src/lib/better_launch/docs/paper.md new file mode 100644 index 0000000000..e056953c72 --- /dev/null +++ b/src/lib/better_launch/docs/paper.md @@ -0,0 +1,101 @@ +--- +title: "better_launch: A better replacement for the ROS2 launch system" +tags: + - ROS2 + - launch + - Python +authors: + - name: Nikolas Dahn + orcid: 0000-0003-2262-2989 + affiliation: "1" +affiliations: + - index: 1 + name: German Research Center for Artificial Intelligence, Germany +date: 1 August 2025 +bibliography: paper.bib +--- + +# Summary + +Today, most robots use middleware in order to manage and connect the various pieces of software they need for fulfilling their tasks. Over the last years, the most popular middleware - the Robot Operating System (ROS) - has received a complete overhaul and been released as ROS2. However, the reception so far has been mixed. While the code base has seen several important improvements, this reinvention has also come with severe downgrades in terms of user friendliness and usability. One of the biggest and most exposed offenders is the launch system, responsible for orchestrating the startup and execution of the robot's software components. The software presented in this paper - *better_launch* - is a complete replacement for the ROS2 launch system and solves several technical and usability issues. It is lightweight, intuitive, convenient, fully documented, and comes with many examples. + +# Statement of need + +On one hand, being able to write launch files in Python is a nice feature that does away with many of the limitations of the old ROS1 xml launch files. On the other hand, the current implementation tries to use Python as a declarative language, forcing users to dance around unintuitive constructs as none of the values and nodes exist yet at the time of execution. Aside from its wordiness and significant challenge to new users, it comes with several anti patterns and technical issues, too: + +- execution order of actions is non-deterministic +- dynamic code execution (e.g., passing code as a string for later execution) +- resists linting as many arguments have to be passed as strings +- resists automated code analysis as actions don't have a unified way of exposing internals +- frequent zombie processes +- convoluted and obscure internals + +There are, of course, good reasons for the way the launch system has been implemented, at least on a superficial level. According to the design document [@ros2_launch_design], the intent is to treat launch files as "a description of what will happen" without executing anything. This is so that tools can "visualize and modify the launch description". The recently released LaunchMap [@launchmap] is able to do just that. However, given that users may define their own launch actions without a common way of inspecting them, it could be argued that even this use case is not well supported right now. There is also no good argument why the same couldn't be achieved using, e.g., Python's inspect module and/or a non-declarative syntax. + +# Existing Remedies + +The issues in user friendliness have led to the emergence of several packages that simplify writing launch files, e.g., Simple_launch [@simple_launch] and Launch-generator [@launch_generator], while other packages like the popular Generate_parameter_library [@generate_parameter_library] are dedicated to particular aspects of the launch process. However, in the end they all face the same issues mentioned above, as they just represent different ways of generating the launch descriptions. + +# better_launch + +In order to actually resolve these issues, I have written *better_launch*, a complete and self-contained replacement for the ROS2 launch system with no dependencies on the existing facilities. Getting started with *better_launch* is easy as it is well-documented and comes with examples for various common use cases. It enables developers to write simple and intuitive launch files using regular Pythonic syntax (see example below). Crucially, it addresses the above issues as follows: + +- Any actions taken in the launch files are executed immediately, allowing direct and meaningful interactions with nodes, topics, and services as the launch process develops. This allows, e.g., adjusting one node's parameters based on another node's published topics. +- Arguments to the launch file are "declared" in the form of function arguments that are passed as natural types, enabling common Python syntax like if-else. +- *better_launch* launch files are fully compatible with the ROS2 launch system. This means they can be started through `ros2 launch`, include regular ROS2 launch files, and even get included from regular ROS2 launch files. +- The entire launch logic is contained within a single, fully documented package, which includes examples. Convenience functions exist for various common tasks like starting a `joint_state_publisher` or bridging Gazebo topics. +- *better_launch* comes with a replacement for `ros2 launch` called `bl`, which is faster than its counterpart and provides additional features. For example, `bl` enables auto completion for launch parameters and uses the launch function's docstring to generate `--help` text. +- To improve usability, *better_launch* reformats and colors terminal output by default. +- Unless killed with SIGKILL, *better_launch* will not leave zombie processes behind. +- *better_launch* provides an optional and unobtrusive **terminal UI**, reminiscent of Rosmon [@rosmon], which can be used for stopping and restarting nodes, triggering life cycle transitions, listing a node's subscribed topics, dynamically adjusting the logging level, and more. + +![Screenshot of the TUI](../media/tui.png){height="60%"} + +We consider *better_launch* mature enough for general use in research applications, with performance similar or better than `ros2 launch` (benchmarks in repo) [@py-spy; @memray; @psutil]. It is under active development and can be downloaded for free from . We hope that *better_launch* will advance the state of ROS2 in a meaningful way. + +\newpage + +# Example + +The ROS2 tutorials provide an example [@ros2_substitution_example] for running a turtlebot simulation. This launch file creates a node, then calls one of its services and updates a parameter. Ironically, due to the asynchronous execution, the parameter update usually fails because the node has not come up yet. *better_launch* does not have this issue, and can in addition express the same launch file with only 28 instead of 73 lines - including documentation! + +```python +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + +@launch_this +def great_atuin( + turtlesim_ns: str = "turtlesim1", + use_provided_red: bool = True, + new_background_r: int = 200, +): + """ This docstring will also be used to create text for --help!""" + bl = BetterLaunch() + + with bl.group(turtlesim_ns): + turtle_node = bl.node( + package="turtlesim", + executable="turtlesim_node", + name="sim", + params={"background_r": 120}, + ) + + bl.call_service( + topic=f"/{turtlesim_ns}/spawn", + service_type="turtlesim/srv/Spawn", + request_args={"x": 2.0, "y": 2.0, "theta": 0.2}, + ) + + if use_provided_red: + turtle_node.is_ros2_connected(timeout=None) + turtle_node.set_live_params({"background_r": new_background_r}) +``` + +\newpage + +# AI usage disclosure + +No generative AI tools were used in the development of this software, the writing +of this manuscript, or the preparation of supporting materials. + +# References diff --git a/src/lib/better_launch/docs/paper.pdf b/src/lib/better_launch/docs/paper.pdf new file mode 100644 index 0000000000..9947fda45a Binary files /dev/null and b/src/lib/better_launch/docs/paper.pdf differ diff --git a/src/lib/better_launch/docs/requirements.txt b/src/lib/better_launch/docs/requirements.txt new file mode 100644 index 0000000000..94322def7c --- /dev/null +++ b/src/lib/better_launch/docs/requirements.txt @@ -0,0 +1,3 @@ +mkdocs-material +mkdocstrings[python] +mkdocs-api-autonav diff --git a/src/lib/better_launch/examples/community.md b/src/lib/better_launch/examples/community.md new file mode 100644 index 0000000000..fbd60412be --- /dev/null +++ b/src/lib/better_launch/examples/community.md @@ -0,0 +1,30 @@ +# Community Examples + +> You've written some beautiful launch files and would like to share them with the community? This is the place to collect and find them! + + +## A turtlesim with pixi +**What:** A working `better_launch` + turtlesim example based on pixi, which makes it super easy to set up an isolated ROS/Python workspace. +**Where:** https://github.com/kywch/turtlesim-pixi +**Who:** @kywch + +### Instructions +The turtlesim and teleop work out of the box by running `pixi run turtlesim` and `pixi run teleop`. + +You can run the examples with following commands: + +```sh +# Solve and install all dependencies in the virtual environment +pixi shell + +# If the install is successful, the venv is activated +# Go to the ROS workspace +cd src +pixi run build + +# Test installation +pixi run bl + +# Run the turtlesim example +pixi run bl better_launch 08_better_turtlesim.launch.py +``` diff --git a/src/lib/better_launch/examples/control_config.yaml b/src/lib/better_launch/examples/control_config.yaml new file mode 100644 index 0000000000..65c0d89dcc --- /dev/null +++ b/src/lib/better_launch/examples/control_config.yaml @@ -0,0 +1,15 @@ +/**: + ros__parameters: + update_rate: 1 # Hz + + # List of controllers + joint_state_broadcaster: + type: joint_state_broadcaster/JointStateBroadcaster + +# Controller specific parameters +joint_state_broadcaster: + ros__parameters: + frame_id: my_awesome_frame + joints: + - joint1 + - joint2 \ No newline at end of file diff --git a/src/lib/better_launch/examples/cube.sdf b/src/lib/better_launch/examples/cube.sdf new file mode 100644 index 0000000000..90687ca906 --- /dev/null +++ b/src/lib/better_launch/examples/cube.sdf @@ -0,0 +1,22 @@ + + + + 0 0 0.5 0 0 0 + + + + + 1 1 1 + + + + + + + 1 1 1 + + + + + + diff --git a/src/lib/better_launch/examples/minimal_robot.urdf b/src/lib/better_launch/examples/minimal_robot.urdf new file mode 100644 index 0000000000..eb4e78b45f --- /dev/null +++ b/src/lib/better_launch/examples/minimal_robot.urdf @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + mock_components/GenericSystem + + + + + + + + + \ No newline at end of file diff --git a/src/lib/better_launch/examples/param_echo_node.py b/src/lib/better_launch/examples/param_echo_node.py new file mode 100755 index 0000000000..bf38266015 --- /dev/null +++ b/src/lib/better_launch/examples/param_echo_node.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +import json + +import rclpy +from rclpy.node import Node +from rclpy.parameter import Parameter +from rcl_interfaces.msg import SetParametersResult +from std_srvs.srv import Trigger + + +class ParamEchoNode(Node): + def __init__(self): + super().__init__( + "param_echo_node", + allow_undeclared_parameters=True, + automatically_declare_parameters_from_overrides=True, + ) + + self.params = { + name: param.value + for name, param in self.get_parameters_by_prefix("").items() + } + self._svc = self.create_service( + Trigger, "get_params", self._get_params_callback + ) + + params_str = "\n".join([ + f" [PARAM] {name} = {param!r}" for name, param in self.params.items() + ]) + self.get_logger().info(params_str) + + self.add_on_set_parameters_callback(self._on_params_set) + + def _on_params_set(self, params: list[Parameter]) -> SetParametersResult: + for p in params: + self.get_logger().info(f" [UPDATE] {p.name} = {p.value!r}") + self.params[p.name] = p.value + + return SetParametersResult(successful=True) + + def _get_params_callback(self, req, res) -> None: + res.message = json.dumps(self.params) + res.success = True + return res + + +def main(args=None): + rclpy.init(args=args) + node = ParamEchoNode() + rclpy.spin(node) + rclpy.shutdown() + + +if __name__ == "__main__": + main() diff --git a/src/lib/better_launch/examples/python/01_basic_example.launch.py b/src/lib/better_launch/examples/python/01_basic_example.launch.py new file mode 100644 index 0000000000..86ec8c4f86 --- /dev/null +++ b/src/lib/better_launch/examples/python/01_basic_example.launch.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def first_steps(): + """ + This is how nice your launch files could be! + + You can run this launch file either directly or with the included `bl` script, which should be on your PATH once you have built better_launch and sourced your workspace: + + .. code:: bash + + bl better_launch 01_basic_example.py + + NOTE: All functions come with proper documentation, which you can also find at `../docs/build/html/index.html`. + """ + bl = BetterLaunch() + + if not bl.is_included(): + print("I am a strong, independent launchfile!") + + with bl.group("basic"): + bl.node( + "examples_rclpy_minimal_publisher", + "publisher_local_function", + "my_talker", + ) + + # Convenience and comfort at your behest :) + msg = bl.receive_message("/basic/topic", "std_msgs/msg/String", None, timeout=5.0) + print(f"\n### Oh hey, I received a message :D\n{msg}\n") + + bl.node( + "examples_rclpy_minimal_subscriber", + "subscriber_member_function", + "my_listener", + ) diff --git a/src/lib/better_launch/examples/python/02_ui.launch.py b/src/lib/better_launch/examples/python/02_ui.launch.py new file mode 100644 index 0000000000..bf15f690be --- /dev/null +++ b/src/lib/better_launch/examples/python/02_ui.launch.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +# NOTE This is the new part! +@launch_this(ui=True) +def a_nice_ui(): + """ + This example starts the same nodes as the previous one, but uses a terminal UI for display and management. Use tab/shit-tab/enter/escape to navigate the submenus and see what you can do! + + In case some of the default keybindings don't work for you, it is possible to specify overrides as below. More information can be found in the better_tui documentation! + + BL_TUI_KEYBINDS="nodes: c-n; loglevel: c-l" bl better_launch 02_ui.launch.py + """ + bl = BetterLaunch() + + with bl.group("basic"): + bl.node( + "examples_rclpy_minimal_publisher", + "publisher_local_function", + "my_talker", + ) + bl.node( + "examples_rclpy_minimal_subscriber", + "subscriber_member_function", + "my_listener", + ) diff --git a/src/lib/better_launch/examples/python/03_composition.launch.py b/src/lib/better_launch/examples/python/03_composition.launch.py new file mode 100644 index 0000000000..033f2e0dab --- /dev/null +++ b/src/lib/better_launch/examples/python/03_composition.launch.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this +from better_launch.elements import Component + + +@launch_this(ui=True) +def not_a_song(): + """ + This example will create a Composer node and load two example plugins into it from the composition package: + * composition/composition::Talker + * composition/composition::Listener + + Using the terminal UI (the TUI), you will see how they appear as a single node. Opening the node dialog from the sidebar will also allow you to unload individual components or kill the entire composer. + """ + bl = BetterLaunch() + + with bl.compose("my_composer"): + # We load the first component immediately + # For the purpose of this tutorial we'll create the listener later + bl.component("composition", "composition::Talker", "comp_talker") + + # bl.compose returns a Composer that we could reuse, but even without it's possible to reuse an + # already running composer node (even if it wasn't started with better_launch!) + with bl.compose("my_composer", reuse_existing=True) as composer: + # Instead of calling bl.component we can also create the Component directly + listener = Component( + composer, + package="composition", + plugin="composition::Listener", + name="comp_listener", + namespace="/", # must be provided for manual instantiation + ) + + # Two ways to start the component + # composer.load_component(comp2) + listener.start() diff --git a/src/lib/better_launch/examples/python/04_lifecycle.launch.py b/src/lib/better_launch/examples/python/04_lifecycle.launch.py new file mode 100644 index 0000000000..e86edfe81c --- /dev/null +++ b/src/lib/better_launch/examples/python/04_lifecycle.launch.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this, LifecycleStage + + +@launch_this(ui=True) +def from_the_cradle_to_the_grave(): + """In this example, two separate nodes that support lifecycle management will be started: + * lifecycle/lifecycle_talker + * lifecycle/lifecycle_listener + + The talker will be started in the PRISTINE stage, which means that its process will be started, but the node itself will idle in its UNCONFIGURED state. Using the TUI's node menu from the sidebar, you can then ask the talker node to transition into its ACTIVE stage. The listener will already be ACTIVE once this function returns. + """ + bl = BetterLaunch() + + # This one you can activate from the TUI + bl.node( + "lifecycle", + "lifecycle_talker", + "start_me", + lifecycle_target=LifecycleStage.PRISTINE, + ) + + # Nodes will transition to ACTIVE by default (if they come up fast enough), but for the sake + # of this tutorial we will transition it manually + listener = bl.node( + "lifecycle", + "lifecycle_listener", + "lc_listener", + lifecycle_target=LifecycleStage.PRISTINE, + ) + + # The only way to see from the outside if a node supports lifecycle management is to check + # whether it has initialized the required topics and services + if listener.is_lifecycle_node(timeout=5.0): + listener.lifecycle.transition(LifecycleStage.ACTIVE) diff --git a/src/lib/better_launch/examples/python/05_launch_arguments.launch.py b/src/lib/better_launch/examples/python/05_launch_arguments.launch.py new file mode 100644 index 0000000000..d4aa3a46ee --- /dev/null +++ b/src/lib/better_launch/examples/python/05_launch_arguments.launch.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def handle_with_care(enable: bool = False): + """ + When writing a better_launch launch file, every argument of your launch function will be exposed on the command line. When running the launch file either directly or via `bl` you can pass this argument as follows: + + .. code:: bash + + bl better_launch 05_launch_arguments.py --enable True + + These arguments can be used directly in your launch code without the need for conditions and substitutions. And in case you want to know what your launch file can actually do, you can always pass `--help` to it - try it out! + + Parameters + ---------- + enable : bool, optional + Whether to start the listener node. + """ + bl = BetterLaunch() + + if not enable: + # Can also pass logging.ERROR instead of a string for the severity + bl.log("error", "This launch file must be run with `--enable True`!") + + if bl.is_included(): + # For example 06 + bl.log("warning", f"Example 05 was included by {bl.launchfile}") + + if enable: + bl.node( + "examples_rclpy_minimal_publisher", + "publisher_local_function", + "my_talker", + ) + + # Yay, no more clunky condition substitutions! + bl.node( + "examples_rclpy_minimal_subscriber", + "subscriber_member_function", + "my_listener", + ) diff --git a/src/lib/better_launch/examples/python/06_includes.launch.py b/src/lib/better_launch/examples/python/06_includes.launch.py new file mode 100644 index 0000000000..c85e9589cf --- /dev/null +++ b/src/lib/better_launch/examples/python/06_includes.launch.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this(ui=True) +def tis_a_hungry_function(): + """ + better_launch can include other launch files and pass arbitrary arguments to them. + + You can also include ROS2 launch files (.py, .yaml, .xml). Be aware though that including ROS2 launch files requires running an instance of the ROS2 launch service, which creates a lot of overhead. If possible it is always better to stick to only one launch system. This will be discussed more in the next example. + """ + bl = BetterLaunch() + + # If no package is given, the current launch file's package is used. Additional keyword + # arguments are passed as launch arguments. We could also specify pass_launch_func_args to pass + # all arguments of this launch function. + bl.include(None, "05_launch_arguments.launch.py", enable=True) + + # Since better_launch executes actions immediately, you can also interact with your nodes immediately! + talker = bl.query_node("my_talker") + bl.logger.info(f""" +====================== +TALKER IS ALIVE: {talker.is_ros2_connected()} +====================== +""") diff --git a/src/lib/better_launch/examples/python/07_ros2_turtlesim.launch.py b/src/lib/better_launch/examples/python/07_ros2_turtlesim.launch.py new file mode 100644 index 0000000000..66007d719b --- /dev/null +++ b/src/lib/better_launch/examples/python/07_ros2_turtlesim.launch.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this +import time + +# This is what we usually want to avoid, but for the sake of this tutorial... +from launch_ros.actions import Node +from launch.actions import ExecuteProcess, TimerAction +from launch.conditions import IfCondition +from launch.substitutions import PythonExpression + + +@launch_this +def a_relic_of_the_past( + turtlesim_ns: str = "turtlesim1", + use_provided_red: bool = False, + new_background_r: int = 200, + kill_after: float = 10.0, +): + """ + This example reimplements the `turtlesim example`_ from the ROS2 documentation. + + When including ROS2 launch actions, better_launch creates a child process which will run the ROS2 `LaunchService` instance which will handle the passed actions asynchronously in the background. This is also true for including ROS2 launch files. + + Unfortunately, due to their ludicrous complexity, it is not feasible to digest and analyze what these actions are doing, especially when better_launch was written as an alternative. For this reason, anything started via the ROS2 launch system by better_launch will appear under a single 'LaunchService' node in the TUI. + """ + bl = BetterLaunch() + + # If you really want to you can do this, but I will heavily frown on you + bl.ros2_actions( + Node( + package='turtlesim', + namespace=turtlesim_ns, + executable='turtlesim_node', + name='sim' + ), + ExecuteProcess( + cmd=[[ + 'ros2 service call ', + turtlesim_ns, + '/spawn ', + 'turtlesim/srv/Spawn ', + '"{x: 2, y: 2, theta: 0.2}"' # wtf is this?! + ]], + shell=True + ), + ExecuteProcess( + cmd=[[ + 'ros2 param set ', + turtlesim_ns, + '/sim background_r ', + '120' + ]], + shell=True + ), + TimerAction( + period=2.0, + actions=[ + ExecuteProcess( + condition=IfCondition( + PythonExpression([ + str(new_background_r), # yes, this is necessary + ' == 200', + ' and ', + str(use_provided_red) + ]) + ), + cmd=[[ + 'ros2 param set ', + turtlesim_ns, + '/sim background_r ', + str(new_background_r) + ]], + shell=True + ) + ] + ), + ) + + # When running without the TUI, the launch function will run on the main thread. In the TUI however, the launch function will be executed on a separate thread, so this will still be okay + bl.logger.info(f"Waiting for {kill_after}s before shutting down turtlesim") + time.sleep(kill_after) + + # Let's tear down this mess. The next example will show how to do better + bl.ros2_launch_service().shutdown("We regret everything") diff --git a/src/lib/better_launch/examples/python/08_better_turtlesim.launch.py b/src/lib/better_launch/examples/python/08_better_turtlesim.launch.py new file mode 100644 index 0000000000..464e795c88 --- /dev/null +++ b/src/lib/better_launch/examples/python/08_better_turtlesim.launch.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this +import time + +@launch_this +def great_atuin( + turtlesim_ns: str = "turtlesim1", + use_provided_red: bool = True, + new_background_r: int = 200, +): + """This launch file implements the same functionality as the previous turtlesim example, but using better_launch functionality instead of ROS2. + + Parameters + ---------- + turtlesim_ns : str, optional + Namespace of the turtlesim node. + use_provided_red : bool, optional + Allow changing the background color. + new_background_r : int, optional + Must be 200 to do anything. + """ + bl = BetterLaunch() + + with bl.group(turtlesim_ns): + turtle_node = bl.node( + package="turtlesim", + executable="turtlesim_node", + name="sim", + # Pass parameters directly + params={"background_r": 120}, + ) + + bl.call_service( + topic=f"/{turtlesim_ns}/spawn", + service_type="turtlesim/srv/Spawn", + request_args={"x": 2.0, "y": 2.0, "theta": 0.2}, + ) + + if use_provided_red: + turtle_node.is_ros2_connected(timeout=None) + + # Not needed, but this way it's noticable when we change the color + time.sleep(1.0) + + turtle_node.set_live_params({"background_r": new_background_r}) + new_r = turtle_node.get_live_params('background_r') + print(f"turtle node's new background_r is now: {new_r}") diff --git a/src/lib/better_launch/examples/python/09_convenience.launch.py b/src/lib/better_launch/examples/python/09_convenience.launch.py new file mode 100644 index 0000000000..6209493623 --- /dev/null +++ b/src/lib/better_launch/examples/python/09_convenience.launch.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this, convenience + + +@launch_this +def so_comfortable(): + """ + The convenience module provides shortcuts for a couple of tasks that are common enough to be nice to have, yet too specific to include in the main API. + """ + # Still need to instantiate BetterLaunch first + bl = BetterLaunch() + + convenience.static_transform_publisher("world", "better_launch", (1, 1, 1)) + + # Note that the rviz2 executable is in fact not a ROS2 node. Under the hood the convenience + # function below calls bl.node(..., raw=True) to avoid passing any unexpected node arguments. + convenience.rviz() diff --git a/src/lib/better_launch/examples/python/10_gazebo.launch.py b/src/lib/better_launch/examples/python/10_gazebo.launch.py new file mode 100644 index 0000000000..4b7fe861ce --- /dev/null +++ b/src/lib/better_launch/examples/python/10_gazebo.launch.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this, gazebo + + +@launch_this +def over_stimulation(): + """ + An example for starting a very simple gazebo simulation, consisting of an empty world into + which we spawn a cube model. + """ + # Still need to instantiate BetterLaunch first + bl = BetterLaunch() + bl.logger.info("Gazebo version: %s", gazebo.get_gazebo_version()) + + # Not relevant here, but you can set use_sim_time for all nodes within a group. This + # setting will also be inherited by nested groups - unless they override it themselves! + with bl.group("sim", use_sim_time=True): + gazebo.gazebo_launch("better_launch", "test.world") + gazebo.spawn_model("cube", bl.find("better_launch", "cube.sdf")) + gazebo.spawn_topic_bridge( + gazebo.GazeboBridge.clock_bridge(), + remaps={"/clock": "/gz_clock"}, + ) + gazebo.spawn_image_bridge( + gazebo.GazeboBridge("/camera", "sensor_msgs/msg/Image", "gz2ros") + ) + gazebo.spawn_world_transform() + + bl.logger.info("The loaded gazebo world is %s", gazebo.get_active_world_name()) diff --git a/src/lib/better_launch/examples/python/11_performance.launch.py b/src/lib/better_launch/examples/python/11_performance.launch.py new file mode 100644 index 0000000000..7c533f95d7 --- /dev/null +++ b/src/lib/better_launch/examples/python/11_performance.launch.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def per_aspera_ad_astra(): + """ + This launch file is used for the benchmarks. It's the same as the basic example, except that we start the nodes manually. + + Any interactions with ROS2, such as querying topics, creating services, publishers, subscribers, etc., or even just checking whether a specific node is alive, require a ROS2 node instance. better_launch instantiates this internal node on an as-needed basis. + + By starting the nodes manually we avoid some checks the launcher would usually run, like whether the node is alive or if it's a lifecycle node. This avoids all ROS2 interactions, so the internal node is never started, saving some resources. + """ + bl = BetterLaunch() + + with bl.group("basic"): + talker = bl.node( + "examples_rclpy_minimal_publisher", + "publisher_local_function", + "my_talker", + autostart_process=False, + ) + talker.start() + + listener = bl.node( + "examples_rclpy_minimal_subscriber", + "subscriber_member_function", + "my_listener", + autostart_process=False, + ) + listener.start() + + bl.logger.info(f"This is the ROSAdapter instance: {bl._ros_adapter}") diff --git a/src/lib/better_launch/examples/python/12_controllers.launch.py b/src/lib/better_launch/examples/python/12_controllers.launch.py new file mode 100644 index 0000000000..3fb5b851db --- /dev/null +++ b/src/lib/better_launch/examples/python/12_controllers.launch.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this, convenience +from pprint import pprint + + +@launch_this +def parambolage(): + """This file demonstrates how to start a controller_manager and load a simple controller. + + Since ROS2 doesn't have a central parameter server, each process stores the parameters passed to it on their own. These will be used anytime a node is spawned within this process. This can lead to problems when using e.g. generic remaps on a process creating multiple nodes (e.g. composers, ros2 control manager, etc.), as all arguments (params, remaps, etc.) will apply to them - including names and namespaces. + + More details on this can be found here: https://control.ros.org/humble/doc/ros2_control/controller_manager/doc/userdoc.html#using-the-controller-manager-in-a-process. + + In order to avoid the aforementioned problems, params and remaps can be prefixed with a namespace or node name in order to make them more selective. This is done automatically when using `BetterLaunch.load_params`. + """ + bl = BetterLaunch() + + # We could also pass the params file path to the node, but this way we have a bit + # more control. + params = bl.load_params("better_launch", "control_config.yaml") + print("Loaded parameters:") + pprint(params, indent=2) + + # We need a robot state publisher to load the URDF and publish it on [/robot_desciption] + convenience.robot_state_publisher( + bl.find( + package="better_launch", + filename="minimal_robot.urdf", + ), + node_name="robot_state_publisher", + anonymous=False + ) + + remaps = {} + if bl.ros_distro_key() < "j": + # In versions before Jazzy the controller_manager was subscribing to something weird like + # /controller_manager/robot_description + remaps["~/robot_description"] = "/robot_description" + + # NOTE we could use convenience.spawn_controller_manager, but this launch file also serves to + # show how to load parameters and some more technical nuances + bl.node( + package="controller_manager", + executable="ros2_control_node", + name="controller_manager", + params=params, + remaps=remaps, + # Qualify the name and namespace remaps with the ORIGINAL name of the manager. The original + # name is passed to the node's implementation on construction and usually hardcoded. + # See https://github.com/ros-controls/ros2_control/blob/6cbb5f0ff69c1abcab97dde4f99ccc84d0b3f5bb/controller_manager/src/ros2_control_node.cpp#L58 + remap_qualifier="controller_manager", + ) + + # Discover the manager node even if we didn't keep a reference to it + manager = bl.query_node("controller_manager") + print("Retrieving controller manager parameters") + pprint(manager.get_live_params()) + + convenience.spawn_controller("joint_state_broadcaster") + print("Running ROS2 nodes:") + pprint(bl.all_ros2_node_names()) + + # TODO since the controller is spawned inside the controller manager's process, it is + # currently not possible to interact with it from better_launch. I will find a way... + #controller = bl.query_node("/joint_state_broadcaster") + #print(controller.get_live_params()) + + ret = bl.call_service( + "/joint_state_broadcaster/get_parameters", + "rcl_interfaces/srv/GetParameters", + request_args={"names": ["frame_id"]}, + ) + print(f"\n=> Is my frame awesome? {ret.values[0].string_value}\n") diff --git a/src/lib/better_launch/examples/python/13_rosbag.launch.py b/src/lib/better_launch/examples/python/13_rosbag.launch.py new file mode 100644 index 0000000000..58e2268431 --- /dev/null +++ b/src/lib/better_launch/examples/python/13_rosbag.launch.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +from fnmatch import fnmatch +from better_launch import BetterLaunch, launch_this, convenience + + +@launch_this +def kneel_and_bag(pattern: str = "*topic*"): + """ + This example mostly serves to raise awareness of one very nice convenience function :) + """ + bl = BetterLaunch() + + def should_record(topic: str) -> bool: + if not fnmatch(topic, pattern): + print("(in a growly voice) REJECTED!", topic, pattern) + return False + + # Just some example code to select topics of type std_msgs/String + topics_and_types = dict(bl.shared_node.get_topic_names_and_types()) + + for tp in topics_and_types.get(topic, []): + if tp == "std_msgs/msg/String": + return True + + return False + + bl.node( + "examples_rclpy_minimal_publisher", + "publisher_local_function", + "my_talker", + ) + + # Record all topics that fulfill the should_record predicate + convenience.record_topics( + None, + should_record, + max_bag_size = 0.1, # split after 0.1 MB + recordings = 3, # record 3 bags in total + ) diff --git a/src/lib/better_launch/examples/python/14_gdb.launch.py b/src/lib/better_launch/examples/python/14_gdb.launch.py new file mode 100644 index 0000000000..369ad2a115 --- /dev/null +++ b/src/lib/better_launch/examples/python/14_gdb.launch.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this + + +@launch_this +def stop_bugging_me(): + """ + If you want to debug a node or execute it by some other means you can do so easily through the node's `exec_args`. These will be prepended to the node's run command. + """ + bl = BetterLaunch() + + bl.node( + # Use the cpp version this time so gdb can actually debug it + "examples_rclcpp_minimal_publisher", + "publisher_member_function", + "my_talker", + # Strings with quoted sections will be split correctly thanks to shlex + exec_args="xterm -e gdb -ex run --args", + ) diff --git a/src/lib/better_launch/examples/ros2_include.launch.py b/src/lib/better_launch/examples/ros2_include.launch.py new file mode 100644 index 0000000000..230238d10f --- /dev/null +++ b/src/lib/better_launch/examples/ros2_include.launch.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 + +from launch_ros.substitutions import FindPackageShare + +from launch import LaunchDescription +from launch.actions import IncludeLaunchDescription +from launch.launch_description_sources import PythonLaunchDescriptionSource +from launch.substitutions import PathJoinSubstitution +from launch_ros.actions import Node + + +def generate_launch_description(): + # Hnnnngggggggg..... + return LaunchDescription( + [ + IncludeLaunchDescription( + PythonLaunchDescriptionSource( + [ + PathJoinSubstitution( + [ + FindPackageShare("better_launch"), + "examples", + "01_basic_example.launch.py", + ] + ) + ] + ), + ), + Node( + package="examples_rclpy_minimal_subscriber", + executable="subscriber_member_function", + name="my_listener_ROS", + namespace="basic", + ), + ] + ) diff --git a/src/lib/better_launch/examples/ros2_performance.launch.py b/src/lib/better_launch/examples/ros2_performance.launch.py new file mode 100644 index 0000000000..015d60e268 --- /dev/null +++ b/src/lib/better_launch/examples/ros2_performance.launch.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +from launch import LaunchDescription +from launch_ros.actions import Node + + +def generate_launch_description(): + """Previous benchmarks were using the turtlesim launch files to compare the performance of better_launch and ROS2. However, that's not a fair comparison, as ROS2 runs the service and parameter updates in separate processes which will not appear in the memory and CPU evaluations. + """ + return LaunchDescription([ + Node( + package="examples_rclpy_minimal_publisher", + executable="publisher_local_function", + name="my_talker", + namespace="basic", + ), + Node( + package="examples_rclpy_minimal_subscriber", + executable="subscriber_member_function", + name="my_listener", + namespace="basic", + ), + ]) diff --git a/src/lib/better_launch/examples/ros2_turtlesim.launch.py b/src/lib/better_launch/examples/ros2_turtlesim.launch.py new file mode 100644 index 0000000000..c9a25aa8d0 --- /dev/null +++ b/src/lib/better_launch/examples/ros2_turtlesim.launch.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument, ExecuteProcess, TimerAction +from launch.conditions import IfCondition +from launch.substitutions import LaunchConfiguration, PythonExpression +from launch_ros.actions import Node + + +def generate_launch_description(): + turtlesim_ns = LaunchConfiguration('turtlesim_ns') + use_provided_red = LaunchConfiguration('use_provided_red') + new_background_r = LaunchConfiguration('new_background_r') + + return LaunchDescription([ + DeclareLaunchArgument( + 'turtlesim_ns', + default_value='turtlesim1' + ), + DeclareLaunchArgument( + 'use_provided_red', + default_value='False' + ), + DeclareLaunchArgument( + 'new_background_r', + default_value='200' + ), + Node( + package='turtlesim', + namespace=turtlesim_ns, + executable='turtlesim_node', + name='sim' + ), + ExecuteProcess( + cmd=[[ + 'ros2 service call ', + turtlesim_ns, + '/spawn ', + 'turtlesim_msgs/srv/Spawn ', + '"{x: 2, y: 2, theta: 0.2}"' + ]], + shell=True + ), + ExecuteProcess( + cmd=[[ + 'ros2 param set ', + turtlesim_ns, + '/sim background_r ', + '120' + ]], + shell=True + ), + TimerAction( + period=2.0, + actions=[ + ExecuteProcess( + condition=IfCondition( + PythonExpression([ + new_background_r, + ' == 200', + ' and ', + use_provided_red + ]) + ), + cmd=[[ + 'ros2 param set ', + turtlesim_ns, + '/sim background_r ', + new_background_r + ]], + shell=True + ), + ], + ) + ]) \ No newline at end of file diff --git a/src/lib/better_launch/examples/test.world b/src/lib/better_launch/examples/test.world new file mode 100644 index 0000000000..25ad0e8249 --- /dev/null +++ b/src/lib/better_launch/examples/test.world @@ -0,0 +1,5 @@ + + + + + diff --git a/src/lib/better_launch/examples/test_params.yaml b/src/lib/better_launch/examples/test_params.yaml new file mode 100644 index 0000000000..c3151db6f8 --- /dev/null +++ b/src/lib/better_launch/examples/test_params.yaml @@ -0,0 +1,8 @@ +/**: + ros__parameters: + always_around: True + +/test: + param_node: + ros__parameters: + pleasure_to_bug_you: "orz" diff --git a/src/lib/better_launch/examples/toml/01_basic_example.launch.toml b/src/lib/better_launch/examples/toml/01_basic_example.launch.toml new file mode 100644 index 0000000000..290e3f20f9 --- /dev/null +++ b/src/lib/better_launch/examples/toml/01_basic_example.launch.toml @@ -0,0 +1,41 @@ +# An example for better_launch's declarative TOML launchfiles. The general +# functionality will still be covered by the python launch files, while this +# section will cover the TOML pecularities. +# +# Also the first comment block becomes the launchfile's help text :) + +# This block will call `BetterLaunch.is_included` and store the returned value +# in a variable called `included`. +[included] +func = "is_included" + +# A block can call any public function of the BetterLaunch object. Any arguments +# to that function are passed as key-value pairs. +[print_hello] +# You may also specify `if` and `unless` conditions for a block. Substitutions +# will be explained later in detail, but the most basic ones allow you to refer +# to the results of previous blocks. The return value of skipped blocks is `None` +# (i.e. their identifiers can still be used in substitutions). +if = "${included}" +func = "log" +severity = "info" +message = "I am a strong, independent launchfile!" + +# To place nodes (or function calls really) inside a group you first need to +# create the group... +[basic_group] +func = "group" +namespace = "basic" + +# ... and then use this slightly different block header. +[basic_group.children.pub_node] +func = "node" +package = "examples_rclpy_minimal_publisher" +executable = "publisher_local_function" +name = "my_talker" + +[basic_group.children.sub_node] +func = "node" +package = "examples_rclpy_minimal_subscriber" +executable = "subscriber_member_function" +name = "my_listener" diff --git a/src/lib/better_launch/examples/toml/02_launch_args.launch.toml b/src/lib/better_launch/examples/toml/02_launch_args.launch.toml new file mode 100644 index 0000000000..4bf359019e --- /dev/null +++ b/src/lib/better_launch/examples/toml/02_launch_args.launch.toml @@ -0,0 +1,26 @@ +# TOML launch files also support required and optional launch arguments. + +# Any keys that are not part of a table (i.e. are on the global level) are considered +# launch arguments. In order to support required launch args you can assign a primitive +# python type to the variable. The launch arg below is a bool with no default. +enable = bool + +# All better_launch switches accepted by the CLI can be set on the global level, too. +# As usual, the priority is env < launch file < CLI. +bl_ui = true + +# Comments above any block become help text when run with --help. +pkg = "better_launch" + +[a_simple_cube] +# Only run if pkg was defined (based on python truthiness). You can also use "unless". +if = "${enable}" +func = "find" +package = "${pkg}" +filename = "cube.sdf" + +# Blocks are executed in sequence, so we can now use the result of the previous one. +[print_me_baby] +func = "log" +severity = "info" +message = "SUCCESS! A simple cube can be found at ${a_simple_cube}" diff --git a/src/lib/better_launch/examples/toml/03_substitutions.launch.toml b/src/lib/better_launch/examples/toml/03_substitutions.launch.toml new file mode 100644 index 0000000000..db8c37eceb --- /dev/null +++ b/src/lib/better_launch/examples/toml/03_substitutions.launch.toml @@ -0,0 +1,74 @@ +# If you've written ROS1 xml launch files in the past you may recall using +# substitutions for passing values around and doing some more complex things. +# better_launch's TOML launch files let you do the same and some more. +# +# better_launch supports the following substitutions: +# - toml (used if no substitution key is provided) +# - eval +# - env +# - param + +# ============================================================================== + +# One of the substitutions allows you to evaluate python code. Since this is +# obviously a security risk the default mode is `none` which will raise an +# exception if the substitution is used. The eval modes are: +# - none: raises an exception if an eval substitution is used +# - literal: use `ast.literal_eval` to parse plain values +# - full: use the real `eval` for evaluation +# +# Note that the eval mode is never inherited on includes and only set for the +# launch file it is defined in. +bl_eval_mode = "full" + +# We need a temporary node for later, don't worry about it +[tmp_node] +func = "node" +package = "examples_rclpy_minimal_subscriber" +executable = "subscriber_member_function" +name = "temp_node" + +# ============================================================================== + +# You already know the most basic type of substitution: TOML substitutions. +# These will simply insert the value from a previous block (or variable +# defined on the global level) wherever they are encountered. +[sub_toml] +func = "log" +severity = "warning" +message = "The current eval mode is: ${bl_eval_mode}" + +# To use one of the non-TOML substitutions, add the substitution name followed +# by a colon : in front, followed by a space separated list of the substitution +# arguments you wish to pass. +[sub_eval] +func = "log" +severity = "info" +# Eval will join all arguments using spaces, but you can also use quotes for +# more complex strings that contain brackets or might confuse the parser. +# Note that even full eval does not allow imports and the globals it sees are +# all populated by this launch file. +message = "[SUB] Some python math: ${eval: ['${bl_eval_mode}'] * 4}" + +# The `env` is pretty straight forward and will simply return the value of the +# environment variable. You may optionally pass a default value. +[sub_env] +func = "log" +severity = "info" +message = "[SUB] ROS distro: ${env: ROS_DISTRO unknown}" + +# There is also a `param` substitution which will retrieve a ROS parameter value +# from a fully qualified node. +[sub_param] +func = "log" +severity = "info" +message = "[SUB] use_sim_time: ${param: /temp_node use_sim_time}" + +# ============================================================================== + +[sleep] +func = "sleep" +seconds = 2.0 + +[shutdown] +func = "shutdown" diff --git a/src/lib/better_launch/examples/toml/04_context.launch.toml b/src/lib/better_launch/examples/toml/04_context.launch.toml new file mode 100644 index 0000000000..439a721771 --- /dev/null +++ b/src/lib/better_launch/examples/toml/04_context.launch.toml @@ -0,0 +1,40 @@ +# TOML files are by design less powerful than python launchfiles. This is intentional, as +# part of their appeal is having a launch description that can be analyzed and verified +# ahead of time. For this reason, calling functions on anything but one of the `contexts` +# is not supported. +# +# There are currently three contexts available: +# - betterlaunch (the default) +# - convenience +# - gazebo + +# ====================================================================== + +# To use a different context, simply pass the `context` parameter to a +# call block, then pass its function args as usual. +[convenience_test] +context = "convenience" +func = "static_transform_publisher" +parent_frame = "world" +child_frame = "better_launch" +pos = [1, 1, 1] + +# ====================================================================== + +# And a gazebo example for good measure +[find_cube] +func = "find" +package = "better_launch" +filename = "cube.sdf" + +[gazebo_launch] +context = "gazebo" +func = "gazebo_launch" +package = "better_launch" +world_file = "test.world" + +[gazebo_cube] +context = "gazebo" +func = "spawn_model" +model_name = "cube" +model = "${find_cube}" diff --git a/src/lib/better_launch/examples/toml/05_turtlesim.launch.toml b/src/lib/better_launch/examples/toml/05_turtlesim.launch.toml new file mode 100644 index 0000000000..2c04601936 --- /dev/null +++ b/src/lib/better_launch/examples/toml/05_turtlesim.launch.toml @@ -0,0 +1,27 @@ +# Just a slightly more complex example + +turtlesim_ns = "turtlesim1" +use_provided_red = false +new_background_r = 200 +new_r = 100 + +[turtle_group] +func = "group" +namespace = "${turtlesim_ns}" + +[turtle_group.children.turtle_node] +func = "node" +package="turtlesim" +executable="turtlesim_node" +name="sim" +params={background_r = 120} + +[snooze] +func = "sleep" +seconds = 1.5 + +[spawn_turtle] +func = "call_service" +topic = "/${turtlesim_ns}/spawn" +service_type = "turtlesim/srv/Spawn" +request_args = {x = 2.0, y = 2.0, theta = 0.2} diff --git a/src/lib/better_launch/examples/verify_params.launch.py b/src/lib/better_launch/examples/verify_params.launch.py new file mode 100644 index 0000000000..ab0eb27415 --- /dev/null +++ b/src/lib/better_launch/examples/verify_params.launch.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +from better_launch import BetterLaunch, launch_this +from std_srvs.srv import Trigger +import json +from pathlib import Path + + +def run(bl: BetterLaunch, params: dict) -> str: + node_path = bl.find("better_launch", "param_echo_node.py") + node = bl.node( + "better_launch", + node_path, + "param_node", + params=params, + ) + + svc = Path(node.namespace) / "get_params" + res = bl.call_service(str(svc), Trigger).message + found = json.loads(res) + print(f"Found: {found}") + + node.shutdown("terminated") + return found + + +def verify(bl: BetterLaunch, found: dict, required: list[str], forbidden: list[str]) -> bool: + valid = True + + for item in required: + if item not in found: + bl.logger.error(f"### Required item '{item}' not found") + valid = False + + for item in forbidden: + if item in found: + bl.logger.error(f"### Forbidden item '{item}' found") + valid = False + + if valid: + bl.logger.info("### Found params valid") + + return valid + + +@launch_this +def do_the_deed(): + bl = BetterLaunch() + + #params = bl.find("better_launch", "test_params.yaml") + params = bl.load_params("better_launch", "test_params.yaml") + print(f"Params: {params}") + + found = run(bl, params) + verify(bl, found, ["always_around"], ["pleasure_to_bug_you"]) + + with bl.group("test"): + found = run(bl, params) + verify(bl, found, ["always_around", "pleasure_to_bug_you"], []) diff --git a/src/lib/better_launch/hooks/bl_shell_completion.bash b/src/lib/better_launch/hooks/bl_shell_completion.bash new file mode 100644 index 0000000000..d1467ad03c --- /dev/null +++ b/src/lib/better_launch/hooks/bl_shell_completion.bash @@ -0,0 +1,30 @@ +# Generated by _BL_COMPLETE=bash_source ./bl + +_bl_completion() { + local IFS=$'\n' + local response + + response=$(env COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD=$COMP_CWORD _BL_COMPLETE=bash_complete $1) + + for completion in $response; do + IFS=',' read type value <<< "$completion" + + if [[ $type == 'dir' ]]; then + COMPREPLY=() + compopt -o dirnames + elif [[ $type == 'file' ]]; then + COMPREPLY=() + compopt -o default + elif [[ $type == 'plain' ]]; then + COMPREPLY+=($value) + fi + done + + return 0 +} + +_bl_completion_setup() { + complete -o nosort -F _bl_completion bl +} + +_bl_completion_setup; diff --git a/src/lib/better_launch/hooks/bl_shell_completion.fish b/src/lib/better_launch/hooks/bl_shell_completion.fish new file mode 100644 index 0000000000..02e6c64246 --- /dev/null +++ b/src/lib/better_launch/hooks/bl_shell_completion.fish @@ -0,0 +1,19 @@ +# Generated by _BL_COMPLETE=fish_source ./bl + +function _bl_completion; + set -l response (env _BL_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) bl); + + for completion in $response; + set -l metadata (string split "," $completion); + + if test $metadata[1] = "dir"; + __fish_complete_directories $metadata[2]; + else if test $metadata[1] = "file"; + __fish_complete_path $metadata[2]; + else if test $metadata[1] = "plain"; + echo $metadata[2]; + end; + end; +end; + +complete --no-files --command bl --arguments "(_bl_completion)"; \ No newline at end of file diff --git a/src/lib/better_launch/hooks/bl_shell_completion.zsh b/src/lib/better_launch/hooks/bl_shell_completion.zsh new file mode 100644 index 0000000000..7ea81b8fa7 --- /dev/null +++ b/src/lib/better_launch/hooks/bl_shell_completion.zsh @@ -0,0 +1,41 @@ +#compdef bl +# Generated by _BL_COMPLETE=zsh_source ./bl + +_bl_completion() { + local -a completions + local -a completions_with_descriptions + local -a response + (( ! $+commands[bl] )) && return 1 + + response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) _BL_COMPLETE=zsh_complete bl)}") + + for type key descr in ${response}; do + if [[ "$type" == "plain" ]]; then + if [[ "$descr" == "_" ]]; then + completions+=("$key") + else + completions_with_descriptions+=("$key":"$descr") + fi + elif [[ "$type" == "dir" ]]; then + _path_files -/ + elif [[ "$type" == "file" ]]; then + _path_files -f + fi + done + + if [ -n "$completions_with_descriptions" ]; then + _describe -V unsorted completions_with_descriptions -U + fi + + if [ -n "$completions" ]; then + compadd -U -V unsorted -a completions + fi +} + +if [[ $zsh_eval_context[-1] == loadautofunc ]]; then + # autoload from fpath, call function directly + _bl_completion "$@" +else + # eval/source/. command, register function for later + compdef _bl_completion bl +fi \ No newline at end of file diff --git a/src/lib/better_launch/mkdocs.yml b/src/lib/better_launch/mkdocs.yml new file mode 100644 index 0000000000..520e660feb --- /dev/null +++ b/src/lib/better_launch/mkdocs.yml @@ -0,0 +1,96 @@ +site_name: better_launch +repo_url: https://github.com/dfki-ric/better_launch +site_url: https://dfki-ric.github.io/better_launch/ +site_author: Nikolas Dahn +site_description: "A better launch system for ROS2" +copyright: Copyright © 2025 DFKI GmbH +theme: + favicon: assets/images/logo.png + logo: assets/images/logo.png + name: material + custom_dir: docs/overrides + font: + text: Noto Sans + features: + - announce.dismiss + - content.code.copy + - header.autohide + - navigation.tabs + - navigation.footer + - navigation.indexes + - navigation.instant + - navigation.tabs + - navigation.tracking + - navigation.top + - search.highlight + - search.share + - search.suggest + palette: + - scheme: slate + primary: deep purple + accent: pink + +nav: + - Home: index.md + - About: + - Why?: about/why.md + - Features: about/features.md + - Differences: about/differences.md + - Performance: about/performance.md + - ROS2: about/ros2.md + - Installation: installation/installation.md + - HowTo: + - Python: howto/python.md + - TOML: howto/toml.md + - TUI: howto/tui.md + - Settings: howto/settings.md + - API Reference + +plugins: + - search + - mkdocstrings: + handlers: + python: + options: + docstring_style: numpy + show_signature_annotations: true + separate_signature: true + show_source: false + show_symbol_type_heading: true + show_symbol_type_toc: true + scoped_crossrefs: true + - api-autonav: + modules: ["better_launch"] + nav_item_prefix: "" + - social: + cards_layout: default/variant + +markdown_extensions: + - admonition + - attr_list + - def_list + - footnotes + - md_in_html + - toc: + toc_depth: "1-3" + permalink: true + # Python Markdown Extensions + - pymdownx.betterem: + smart_enable: all + - pymdownx.caret + - pymdownx.details + - pymdownx.highlight + - pymdownx.inlinehilite + - pymdownx.keys + - pymdownx.mark + - pymdownx.smartsymbols + - pymdownx.details + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tilde + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg \ No newline at end of file diff --git a/src/lib/better_launch/package.xml b/src/lib/better_launch/package.xml new file mode 100644 index 0000000000..73a46d0bd8 --- /dev/null +++ b/src/lib/better_launch/package.xml @@ -0,0 +1,39 @@ + + + better_launch + 1.0.2 + + + A better replacement for the ROS2 launch system: intuitive, simple, memorable. + + + https://dfki-ric.github.io/better_launch/ + https://github.com/dfki-ric/better_launch + https://github.com/dfki-ric/better_launch/issues + + Nikolas Dahn + Nikolas Dahn + + MIT + + ament_cmake_python + ament_cmake_pytest + + ament_index_python + rcl_interfaces + lifecycle_msgs + composition_interfaces + rclpy + + python3-click + python3-docstring-parser + python3-osrf-pycommon + python3-yaml + python3-setproctitle + python3-prompt-toolkit + python3-psutil + + + ament_cmake + + diff --git a/src/lib/better_launch/requirements.txt b/src/lib/better_launch/requirements.txt new file mode 100644 index 0000000000..948a92c28a --- /dev/null +++ b/src/lib/better_launch/requirements.txt @@ -0,0 +1,7 @@ +click~=8.1.7 +docstring_parser~=0.16 +osrf_pycommon~=2.0.2 +PyYAML~=6.0.1 +setproctitle~=1.3.4 +prompt_toolkit~=3.0.50 +psutil~=6.0.0 diff --git a/src/lib/better_launch/setup.py b/src/lib/better_launch/setup.py new file mode 100644 index 0000000000..493e101c4d --- /dev/null +++ b/src/lib/better_launch/setup.py @@ -0,0 +1,21 @@ +from setuptools import setup, find_packages + +setup( + name='better_launch', + version='1.0.2', + packages=find_packages(), + install_requires=[ + "click", + "docstring_parser", + "osrf_pycommon", + "PyYAML", + "setproctitle", + "prompt_toolkit", + "psutil", + ], + tests_require=["pytest"], + maintainer='Nikolas Dahn', + maintainer_email='nikolas.dahn@gmail.com', + description='A better replacement for the ROS2 launch system: intuitive, simple, memorable.', + license='MIT', +) diff --git a/src/lib/better_launch/tests/test_launcher.py b/src/lib/better_launch/tests/test_launcher.py new file mode 100644 index 0000000000..76838bcb47 --- /dev/null +++ b/src/lib/better_launch/tests/test_launcher.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +import pytest +import time +from pathlib import Path +from concurrent.futures import Future + +from better_launch import BetterLaunch +from better_launch.elements import Node + +from launch_ros.actions import Node as RosLaunchNode +from rclpy.qos import qos_profile_parameters + + +# Run tests via `colcon test --packages-select better_launch`. +# Examine results with `colcon test-result --all` + + +@pytest.fixture(scope="session") +def some_function_name(): + # Do any setup stuff before the tests start + yield + + # Once we're done make sure to tear down everything + bl = BetterLaunch.instance() + if bl: + bl.shutdown() + nodes = bl.get_nodes(include_components=True) + still_alive = [not n.is_running for n in nodes] + assert all(still_alive), ( + f"The following nodes refused to shutdown: {still_alive}" + ) + + +@pytest.fixture(autouse=True) +def test_setup(): + yield + # Allow some time to pass for ROS to clean up topics and such + time.sleep(2.0) + + +def _assert_talker_listener_running(talker: Node, listener: Node, topic: str) -> bool: + """Verify the given talker and listener are running and using the given topic.""" + time.sleep(5.0) + + bl = BetterLaunch() + + # Verify talker and listener are alive + alive_nodes = bl.shared_node.get_node_names() + assert talker.name in alive_nodes, ( + f"Talker {talker.name} not listed, alive nodes: {alive_nodes}" + ) + assert listener.name in alive_nodes, ( + f"Listener {listener.name} not listed, alive nodes: {alive_nodes}" + ) + + assert talker.is_running, f"Talker {talker.name} not running" + assert listener.is_running, f"Listener {listener.name} not running" + + # Check correct topic is published/subscribed + assert topic in talker.get_published_topics(), ( + "Talker is not publishing on expected topic" + ) + assert topic in listener.get_subscribed_topics(), ( + "Listener is not subscribed on expected topic" + ) + + # Shutdown talker and listener + talker.shutdown("Test successful", timeout=10.0) + assert not talker.is_running, "Talker failed to shutdown" + + listener.shutdown("Test successful", timeout=10.0) + assert not listener.is_running, "Listener failed to shutdown" + + alive_nodes = bl.shared_node.get_node_names() + assert talker.name not in alive_nodes, f"Talker {talker.name} is still alive" + assert listener.name not in alive_nodes, f"Listener {listener.name} is still alive" + + +def test_bl_init(): + """Test basic initialization""" + bl = BetterLaunch() + assert bl is not None, "BetterLaunch initialized" + + this_file = bl.find(filename=Path(__file__).name, subdir="../**") + assert Path(bl.launchfile) == Path(this_file), "Could not locate test file" + + assert bl.shared_node.count_publishers("rosout") >= 1, "Shared node not working" + + +def test_topic(): + """Test the publish/subscribe helpers.""" + bl = BetterLaunch() + message_future = Future() + + def on_msg(msg): + message_future.set_result(msg) + + msg_type = bl.get_ros_message_type("std_msgs/msg/String") + sub = bl.subscriber( + "/test/better_launch/topic_test", + msg_type, + callback=on_msg, + qos_profile=qos_profile_parameters, + ) + + time.sleep(1.0) + + bl.publish_message( + "/test/better_launch/topic_test", + msg_type, + {"data": "hello world"}, + qos_profile=qos_profile_parameters, + ) + + result = message_future.result(2.0).data + assert result == "hello world", "Failed to receive message" + sub.destroy() + + +def test_node(): + """Verify running regular nodes works.""" + bl = BetterLaunch() + + talker = bl.node( + "examples_rclpy_minimal_publisher", + "publisher_local_function", + "my_talker_node", + remaps={"/topic": "/test/better_launch/chatter_node"}, + ) + listener = bl.node( + "examples_rclpy_minimal_subscriber", + "subscriber_member_function", + "my_listener_node", + remaps={"/topic": "/test/better_launch/chatter_node"}, + ) + + talker.start() + listener.start() + _assert_talker_listener_running( + talker, listener, "/test/better_launch/chatter_node" + ) + + +def test_compose(): + """Verify running composable nodes works.""" + bl = BetterLaunch() + + with bl.compose("my_composer"): + talker = bl.component( + "composition", + "composition::Talker", + "my_talker_comp", + remaps={"/chatter": "/test/better_launch/chatter_comp"}, + ) + + # bl.compose returns a Composer that we could reuse, but even without it's possible to reuse an + # already running composer node (even if it wasn't started with better_launch!) + with bl.compose("my_composer", reuse_existing=True) as composer: + listener = bl.component( + package="composition", + plugin="composition::Listener", + name="my_listener_comp", + remaps={"/chatter": "/test/better_launch/chatter_comp"}, + ) + + _assert_talker_listener_running( + talker, listener, "/test/better_launch/chatter_comp" + ) + + composer.shutdown("Test successful", timeout=10.0) + assert not composer.is_running, "Composer failed to shutdown" + + +def test_include(): + """Verify including other launch files works (better_launch must be installed in the workspace).""" + bl = BetterLaunch() + + bl.include("better_launch", "05_launch_arguments.launch.py", enable=True) + + talker = bl.query_node("/my_talker") + assert talker is not None + + listener = bl.query_node("/my_listener") + assert listener is not None + + # Included example doesn't use remaps + _assert_talker_listener_running(talker, listener, "/topic") + + +def test_ros2_actions(): + """Verify running ROS2 actions works.""" + bl = BetterLaunch() + + ros2 = bl.ros2_actions( + RosLaunchNode( + package="examples_rclpy_minimal_publisher", + executable="publisher_local_function", + name="my_talker_ros2", + remappings=[("topic", "/test/better_launch/chatter_ros2")], + ), + RosLaunchNode( + package="examples_rclpy_minimal_subscriber", + executable="subscriber_member_function", + name="my_listener_ros2", + remappings=[("topic", "/test/better_launch/chatter_ros2")], + ), + ) + + bl.wait_for_topic("/test/better_launch/chatter_ros2", timeout=10.0) + time.sleep(2.0) + + publishers = bl.shared_node.get_publishers_info_by_topic( + "/test/better_launch/chatter_ros2" + ) + assert "my_talker_ros2" in [p.node_name for p in publishers], ( + "Talker is not publishing on expected topic" + ) + + subscribers = bl.shared_node.get_subscriptions_info_by_topic( + "/test/better_launch/chatter_ros2" + ) + assert "my_listener_ros2" in [s.node_name for s in subscribers], ( + "Listener is not listening on expected topic" + ) + + ros2.shutdown("Test successful", timeout=10.0) + assert not ros2.is_running, "ROS2LaunchWrapper failed to shutdown" + + +def test_process_method_signature(): + """Verify process() method has correct signature.""" + import inspect + from better_launch import BetterLaunch + + sig = inspect.signature(BetterLaunch.process) + params = list(sig.parameters.keys()) + + # Must have cmd and name + assert "cmd" in params, "Missing 'cmd' parameter" + assert "name" in params, "Missing 'name' parameter" + + # Must NOT have ROS-specific params + assert "remaps" not in params, "Should not expose 'remaps'" + assert "params" not in params, "Should not expose 'params'" + assert "raw" not in params, "Should not expose 'raw'" + assert "package" not in params, "Should not expose 'package'" + assert "log_level" not in params, "Should not expose 'log_level'" + + # Must have process-relevant params + assert "env" in params, "Missing 'env' parameter" + assert "output" in params, "Missing 'output' parameter" + assert "on_exit" in params, "Missing 'on_exit' parameter" + assert "max_respawns" in params, "Missing 'max_respawns' parameter" + + +def test_process_docstring(): + """Verify process() has proper docstring.""" + from better_launch import BetterLaunch + + doc = BetterLaunch.process.__doc__ + assert doc is not None, "Missing docstring" + assert "Parameters" in doc, "Missing Parameters section" + assert "Returns" in doc, "Missing Returns section" + assert "cmd" in doc, "Missing cmd parameter docs" + + +if __name__ == "__main__": + pytest.main(["-v"]) diff --git a/src/lib/better_launch/tests/test_ros2_parameter_convert.py b/src/lib/better_launch/tests/test_ros2_parameter_convert.py new file mode 100644 index 0000000000..74785ed388 --- /dev/null +++ b/src/lib/better_launch/tests/test_ros2_parameter_convert.py @@ -0,0 +1,152 @@ +import pytest +import json +import sys +from unittest.mock import MagicMock + +# Mock ROS dependencies BEFORE importing better_launch +def mock_package(name): + m = MagicMock() + m.__path__ = [] + sys.modules[name] = m + return m + +mock_rclpy = mock_package("rclpy") +sys.modules["rclpy.node"] = MagicMock() +sys.modules["rclpy.logging"] = MagicMock() +sys.modules["rclpy.qos"] = MagicMock() +sys.modules["rclpy.task"] = MagicMock() +sys.modules["rclpy.executors"] = MagicMock() +sys.modules["rclpy.parameter"] = MagicMock() + +mock_ament = mock_package("ament_index_python") +sys.modules["ament_index_python.packages"] = MagicMock() + +mock_lifecycle_msgs = mock_package("lifecycle_msgs") +sys.modules["lifecycle_msgs.msg"] = MagicMock() +sys.modules["lifecycle_msgs.srv"] = MagicMock() + +mock_launch = mock_package("launch") +sys.modules["launch.actions"] = MagicMock() +sys.modules["launch.launch_description_sources"] = MagicMock() + +from better_launch.launcher import BetterLaunch + + +class TestParameterTranslation: + @pytest.fixture + def bl(self): + # Create a mock BetterLaunch instance to access the method + # We avoid full instantiation to avoid side effects (ROS init, singleton, etc.) + bl = MagicMock(spec=BetterLaunch) + # Bind the method to the mock instance + bl._value_to_yaml = BetterLaunch._value_to_yaml.__get__(bl, BetterLaunch) + return bl + + @pytest.mark.parametrize("input_val, expected_output", [ + (True, "true"), + (False, "false"), + (42, "42"), + (3.14, "3.14"), + ("hello", "hello"), + ("true", "true"), # String "true" should stay "true" + ([1, 2, 3], "[1, 2, 3]"), + ({"a": 1, "b": 2}, '{"a": 1, "b": 2}'), + (None, ""), + ]) + def test_value_to_yaml(self, bl, input_val, expected_output): + """Test that _value_to_yaml correctly converts Python types to ROS2-compatible YAML strings.""" + assert bl._value_to_yaml(input_val) == expected_output + + def test_complex_nested_structure(self, bl): + """Test nested structures.""" + data = {"list": [1, 2], "bool": True, "nested": {"x": "y"}} + # JSON serialization order might vary, but for this simple case it's usually stable. + # However, to be safe, we can parse the output back and compare. + output = bl._value_to_yaml(data) + assert json.loads(output) == data + + def test_string_quoting(self, bl): + """Test that strings are NOT quoted (passed as-is).""" + # ROS2 launch arguments are typically just strings. + # If we pass "foo", it receives "foo". + # If we pass '"foo"', it receives '"foo"'. + assert bl._value_to_yaml("foo") == "foo" + assert bl._value_to_yaml("foo bar") == "foo bar" + + def test_non_serializable(self, bl): + """Test that non-serializable types raise ValueError.""" + class CustomObj: + pass + + with pytest.raises(ValueError, match="Failed to serialize launch argument"): + bl._value_to_yaml(CustomObj()) + + def test_substitutions_passed_through(self, bl): + """Test that ROS2 Substitution objects are passed through unchanged.""" + # Create a dummy object that looks like a Substitution (duck typing) + class MockSubstitution: + def perform(self, context): + return "substituted" + + sub = MockSubstitution() + assert bl._value_to_yaml(sub) is sub + + def test_none_behavior(self, bl): + """Test that None returns an empty string.""" + assert bl._value_to_yaml(None) == "" + + def test_pre_serialized_yaml(self, bl): + """Test that strings looking like YAML/JSON are passed as-is.""" + # If the user manually serialized it, we shouldn't double-encode it + yaml_str = "[1, 2, 3]" + assert bl._value_to_yaml(yaml_str) == yaml_str + + json_str = '{"a": 1}' + assert bl._value_to_yaml(json_str) == json_str + + def test_mixed_types_in_include(self): + """Test mixing primitive types and substitutions in include.""" + bl = BetterLaunch() + bl.ros2_actions = MagicMock() + bl.find = MagicMock(return_value="/dummy/path.launch.py") + + class MockSubstitution: + def perform(self, context): return "val" + + sub = MockSubstitution() + + bl._include_ros2_launchfile( + "/dummy/path.launch.py", + arg1=True, + arg2=sub, + arg3="string" + ) + + # Verify call args + from launch.actions import IncludeLaunchDescription + _, kwargs = IncludeLaunchDescription.call_args + launch_args = dict(kwargs.get("launch_arguments")) + + assert launch_args["arg1"] == "true" + assert launch_args["arg2"] is sub + assert launch_args["arg3"] == "string" + + def test_special_floats(self, bl): + """Test handling of NaN and Infinity.""" + assert bl._value_to_yaml(float("nan")) == ".NaN" + assert bl._value_to_yaml(float("inf")) == ".inf" + assert bl._value_to_yaml(float("-inf")) == "-.inf" + assert bl._value_to_yaml(3.14) == "3.14" + + def test_empty_containers(self, bl): + """Test empty lists and dicts.""" + assert bl._value_to_yaml([]) == "[]" + assert bl._value_to_yaml({}) == "{}" + + def test_describe_only_substitution(self, bl): + """Test a substitution that only has describe() (duck typing).""" + class DescribeOnlySub: + def describe(self): return "description" + + sub = DescribeOnlySub() + assert bl._value_to_yaml(sub) is sub \ No newline at end of file