lte-launch-test 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ Copyright 2026 Spencer Diehl
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
@@ -0,0 +1,2 @@
1
+ # The sdist is the package; tests need their launch files and ROS.
2
+ prune test
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: lte-launch-test
3
+ Version: 0.1.0
4
+ Summary: ROS 2 launch tests on pytest, a launch_testing replacement on libtmux_easy
5
+ Home-page: https://github.com/bigdiehl/lte_launch_test
6
+ Author: Spencer Diehl
7
+ Author-email: docdiehl@gmail.com
8
+ License: Apache-2.0
9
+ Keywords: ros2,ros,launch,testing,pytest,launch_testing
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Framework :: Pytest
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: libtmux-easy>=0.2.0
21
+ Requires-Dist: pytest<9,>=7.0
22
+ Requires-Dist: pyyaml
23
+ Dynamic: license-file
24
+
25
+ # lte_launch_test
26
+
27
+ ROS 2 launch tests on pytest: a replacement for `ros2 launch_test` /
28
+ `launch_testing`. [libtmux_easy](https://github.com/nasa-jpl/libtmux_easy)'s pytest plugin runs the
29
+ processes (subprocesses by default, tmux with `--tmux`); this package adds
30
+ the ROS parts. libtmux_easy stays ROS-agnostic.
31
+
32
+ Processes are shell commands; a launch file is one too (`ros2 launch
33
+ file.py`). Design and `launch_testing` parity: [docs/](docs/README.md).
34
+
35
+ ROS 2 Humble and Jazzy.
36
+
37
+ ## Install
38
+
39
+ | how | |
40
+ |---|---|
41
+ | pip | `pip install lte-launch-test` in a venv made with `--system-site-packages` (it needs ROS's `rclpy`) |
42
+ | `.deb` (Ubuntu 24.04) | from the GitHub Releases, with libtmux_easy's |
43
+ | colcon | in your workspace's `src/`, with `package.xml` |
44
+
45
+ Details: [docs/packaging.md](docs/packaging.md).
46
+
47
+ ## Quick start
48
+
49
+ ```bash
50
+ cd examples
51
+ ./run.sh test_01_topic.py -v # start here; numbered simplest first
52
+ LTE_DEMO_CRASH=1 ./run.sh test_09_tour.py -v # a node dies mid-scenario
53
+ ./run.sh # all of them
54
+ ./run.sh --tmux # the dashboard; --no-attach: detached tmux
55
+ ./run.sh --keep-open # leave every node running after the run
56
+ ./run.sh failing # each fails on purpose
57
+ ```
58
+
59
+ `run.sh` uses ROS as sourced (else `/opt/ros/$ROS_DISTRO`, default Jazzy), puts `src/` on the path, and uses
60
+ `../libtmux_easy/src` if libtmux_easy isn't installed.
61
+
62
+ ## Use
63
+
64
+ ```python
65
+ # conftest.py
66
+ pytest_plugins = ["lte_launch_test.plugin"]
67
+ ```
68
+
69
+ | name | what |
70
+ |---|---|
71
+ | `lte_launch_test.plugin` | a per-run `ROS_DOMAIN_ID` (reserved via `domain_coordinator`) + localhost discovery; every process inherits pytest's environment (ROS and overlays), runs in the rootdir, logs ROS to `$LTE_LOG_DIR/ros`; a `RosProbe` made in a test is destroyed after it |
72
+ | `RosProbe(log_dir=)` | rclpy node (does `rclpy.init()` first; its ROS logs in `<log_dir>/ros_probe`); `with RosProbe() as probe:` in a test, or `RosProbe()` in `setup_class` and `destroy()` in teardown; subscribes with QoS that matches any publisher; waits (`wait_for_nodes`, `wait_for_topics`, `wait_for_services`, `wait_for_messages`, `rate`, `call`, `publish_until`) call `check_health()`, so a death fails them at once |
73
+ | `RosProbe` services, params | `call(Srv, name, a=2, b=40)` (or a request); another node's parameters: `get_param(node, name)`, `get_params`, `set_param(node, name, value)`, `set_params(node, {...})` (fails with the node's reason if rejected), `list_params(node)` |
74
+ | `RosProbe` tf | `wait_for_transform(target, source)`: a `TransformStamped` once tf has it (chains through the tree); the failure lists the transforms it has |
75
+ | `launch_death(allow=)` | `fail_on` check: a node died inside `ros2 launch` (non-zero exit; SIGINT/SIGTERM and exit 0 are fine) |
76
+ | `composable_failure(allow=)` | `fail_on` check: a composable node failed to load (launch's `Failed to load node`, or the container's `Failed to find class` / `Could not find requested resource` / `Failed to load library`) |
77
+ | `wait_for_bag_topics(procs, name, topics, timeout)` | wait for `ros2 bag record` process `name` to subscribe to all its topics; fails naming the missing ones |
78
+ | `check_bag(bag_dir, min_messages)` | after the recorder stopped: fail on each topic below its `{topic: n}` count (from `metadata.yaml`); `bag_counts()`: `{topic: n}` |
79
+ | `Processes`, `check_health`, `arg`, `parametrize` | re-exported from `libtmux_easy.testing` |
80
+
81
+ ## colcon
82
+
83
+ Verified with `colcon test` on Jazzy, for both build types. Put
84
+ `test/conftest.py` (`pytest_plugins = [...]`) next to the tests; libtmux_easy and
85
+ lte_launch_test must be importable (installed, or on `PYTHONPATH`).
86
+
87
+ | build type | add |
88
+ |---|---|
89
+ | `ament_python` | nothing: colcon runs pytest on `test/` |
90
+ | `ament_cmake` | `find_package(ament_cmake_pytest REQUIRED)` and `ament_add_pytest_test(test_talker test/test_talker.py TIMEOUT 120)` |
91
+
92
+ ## Layout
93
+
94
+ | path | what |
95
+ |---|---|
96
+ | `src/lte_launch_test/` | the package |
97
+ | `docs/` | design, `launch_testing` parity, packaging, [future development](docs/FUTURE_DEVELOPMENT.md) |
98
+ | `examples/` | numbered example tests, simplest first; `failing/` fails on purpose. See [examples/](examples/README.md) |
99
+ | `test/` | `test/run.sh`: every failure check, unit and live (launch deaths, container crash, composable load errors, bag topics/counts, probe waits, services, params, tf), and that `examples/failing` fails as documented |
100
+ | `debian/`, `package.xml`, `setup.cfg` | the `.deb`, colcon, pip |
101
+ | `.github/workflows/` | CI (Humble, Jazzy), release |
102
+
103
+ ## Notes
104
+
105
+ - A failed test stops its class's processes at once (SIGINT, escalating;
106
+ `on_abort=` first, if given) and skips the rest of the class.
107
+ `--keep-open` keeps them instead.
108
+ - Logs: each process's output and ROS logs go to pytest's tmp dir, printed
109
+ at the end as `process logs: <dir>`.
110
+ - A node that should exit 0 on SIGINT must catch `KeyboardInterrupt` /
111
+ `ExternalShutdownException` around `rclpy.spin()` (see `examples/nodes/counter_node.py`).
112
+ - A stopped node's name can linger in the graph briefly. Give per-test
113
+ nodes names of their own (`-r __node:=...`).
@@ -0,0 +1,89 @@
1
+ # lte_launch_test
2
+
3
+ ROS 2 launch tests on pytest: a replacement for `ros2 launch_test` /
4
+ `launch_testing`. [libtmux_easy](https://github.com/nasa-jpl/libtmux_easy)'s pytest plugin runs the
5
+ processes (subprocesses by default, tmux with `--tmux`); this package adds
6
+ the ROS parts. libtmux_easy stays ROS-agnostic.
7
+
8
+ Processes are shell commands; a launch file is one too (`ros2 launch
9
+ file.py`). Design and `launch_testing` parity: [docs/](docs/README.md).
10
+
11
+ ROS 2 Humble and Jazzy.
12
+
13
+ ## Install
14
+
15
+ | how | |
16
+ |---|---|
17
+ | pip | `pip install lte-launch-test` in a venv made with `--system-site-packages` (it needs ROS's `rclpy`) |
18
+ | `.deb` (Ubuntu 24.04) | from the GitHub Releases, with libtmux_easy's |
19
+ | colcon | in your workspace's `src/`, with `package.xml` |
20
+
21
+ Details: [docs/packaging.md](docs/packaging.md).
22
+
23
+ ## Quick start
24
+
25
+ ```bash
26
+ cd examples
27
+ ./run.sh test_01_topic.py -v # start here; numbered simplest first
28
+ LTE_DEMO_CRASH=1 ./run.sh test_09_tour.py -v # a node dies mid-scenario
29
+ ./run.sh # all of them
30
+ ./run.sh --tmux # the dashboard; --no-attach: detached tmux
31
+ ./run.sh --keep-open # leave every node running after the run
32
+ ./run.sh failing # each fails on purpose
33
+ ```
34
+
35
+ `run.sh` uses ROS as sourced (else `/opt/ros/$ROS_DISTRO`, default Jazzy), puts `src/` on the path, and uses
36
+ `../libtmux_easy/src` if libtmux_easy isn't installed.
37
+
38
+ ## Use
39
+
40
+ ```python
41
+ # conftest.py
42
+ pytest_plugins = ["lte_launch_test.plugin"]
43
+ ```
44
+
45
+ | name | what |
46
+ |---|---|
47
+ | `lte_launch_test.plugin` | a per-run `ROS_DOMAIN_ID` (reserved via `domain_coordinator`) + localhost discovery; every process inherits pytest's environment (ROS and overlays), runs in the rootdir, logs ROS to `$LTE_LOG_DIR/ros`; a `RosProbe` made in a test is destroyed after it |
48
+ | `RosProbe(log_dir=)` | rclpy node (does `rclpy.init()` first; its ROS logs in `<log_dir>/ros_probe`); `with RosProbe() as probe:` in a test, or `RosProbe()` in `setup_class` and `destroy()` in teardown; subscribes with QoS that matches any publisher; waits (`wait_for_nodes`, `wait_for_topics`, `wait_for_services`, `wait_for_messages`, `rate`, `call`, `publish_until`) call `check_health()`, so a death fails them at once |
49
+ | `RosProbe` services, params | `call(Srv, name, a=2, b=40)` (or a request); another node's parameters: `get_param(node, name)`, `get_params`, `set_param(node, name, value)`, `set_params(node, {...})` (fails with the node's reason if rejected), `list_params(node)` |
50
+ | `RosProbe` tf | `wait_for_transform(target, source)`: a `TransformStamped` once tf has it (chains through the tree); the failure lists the transforms it has |
51
+ | `launch_death(allow=)` | `fail_on` check: a node died inside `ros2 launch` (non-zero exit; SIGINT/SIGTERM and exit 0 are fine) |
52
+ | `composable_failure(allow=)` | `fail_on` check: a composable node failed to load (launch's `Failed to load node`, or the container's `Failed to find class` / `Could not find requested resource` / `Failed to load library`) |
53
+ | `wait_for_bag_topics(procs, name, topics, timeout)` | wait for `ros2 bag record` process `name` to subscribe to all its topics; fails naming the missing ones |
54
+ | `check_bag(bag_dir, min_messages)` | after the recorder stopped: fail on each topic below its `{topic: n}` count (from `metadata.yaml`); `bag_counts()`: `{topic: n}` |
55
+ | `Processes`, `check_health`, `arg`, `parametrize` | re-exported from `libtmux_easy.testing` |
56
+
57
+ ## colcon
58
+
59
+ Verified with `colcon test` on Jazzy, for both build types. Put
60
+ `test/conftest.py` (`pytest_plugins = [...]`) next to the tests; libtmux_easy and
61
+ lte_launch_test must be importable (installed, or on `PYTHONPATH`).
62
+
63
+ | build type | add |
64
+ |---|---|
65
+ | `ament_python` | nothing: colcon runs pytest on `test/` |
66
+ | `ament_cmake` | `find_package(ament_cmake_pytest REQUIRED)` and `ament_add_pytest_test(test_talker test/test_talker.py TIMEOUT 120)` |
67
+
68
+ ## Layout
69
+
70
+ | path | what |
71
+ |---|---|
72
+ | `src/lte_launch_test/` | the package |
73
+ | `docs/` | design, `launch_testing` parity, packaging, [future development](docs/FUTURE_DEVELOPMENT.md) |
74
+ | `examples/` | numbered example tests, simplest first; `failing/` fails on purpose. See [examples/](examples/README.md) |
75
+ | `test/` | `test/run.sh`: every failure check, unit and live (launch deaths, container crash, composable load errors, bag topics/counts, probe waits, services, params, tf), and that `examples/failing` fails as documented |
76
+ | `debian/`, `package.xml`, `setup.cfg` | the `.deb`, colcon, pip |
77
+ | `.github/workflows/` | CI (Humble, Jazzy), release |
78
+
79
+ ## Notes
80
+
81
+ - A failed test stops its class's processes at once (SIGINT, escalating;
82
+ `on_abort=` first, if given) and skips the rest of the class.
83
+ `--keep-open` keeps them instead.
84
+ - Logs: each process's output and ROS logs go to pytest's tmp dir, printed
85
+ at the end as `process logs: <dir>`.
86
+ - A node that should exit 0 on SIGINT must catch `KeyboardInterrupt` /
87
+ `ExternalShutdownException` around `rclpy.spin()` (see `examples/nodes/counter_node.py`).
88
+ - A stopped node's name can linger in the graph briefly. Give per-test
89
+ nodes names of their own (`-r __node:=...`).
@@ -0,0 +1,37 @@
1
+ <?xml version="1.0"?>
2
+ <?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
3
+ <package format="3">
4
+ <name>lte_launch_test</name>
5
+ <version>0.1.0</version>
6
+ <description>ROS 2 launch tests on pytest, a launch_testing replacement on libtmux_easy</description>
7
+ <maintainer email="docdiehl@gmail.com">Spencer Diehl</maintainer>
8
+ <license>Apache-2.0</license>
9
+ <url type="repository">https://github.com/bigdiehl/lte_launch_test</url>
10
+
11
+ <!-- libtmux_easy has no rosdep key: pip install libtmux-easy, or its .deb. -->
12
+ <exec_depend>domain_coordinator</exec_depend>
13
+ <exec_depend>python3-pytest</exec_depend>
14
+ <exec_depend>python3-yaml</exec_depend>
15
+ <exec_depend>rcl_interfaces</exec_depend>
16
+ <exec_depend>rclpy</exec_depend>
17
+ <exec_depend>tf2_ros_py</exec_depend>
18
+ <exec_depend>tmux</exec_depend>
19
+
20
+ <!-- test/ and examples/ -->
21
+ <test_depend>composition</test_depend>
22
+ <test_depend>demo_nodes_cpp</test_depend>
23
+ <test_depend>demo_nodes_py</test_depend>
24
+ <test_depend>example_interfaces</test_depend>
25
+ <test_depend>launch_ros</test_depend>
26
+ <test_depend>rclcpp_components</test_depend>
27
+ <test_depend>ros2bag</test_depend>
28
+ <test_depend>rosbag2</test_depend>
29
+ <test_depend>std_msgs</test_depend>
30
+ <test_depend>std_srvs</test_depend>
31
+ <test_depend>tf2_msgs</test_depend>
32
+ <test_depend>tf2_ros</test_depend>
33
+
34
+ <export>
35
+ <build_type>ament_python</build_type>
36
+ </export>
37
+ </package>
@@ -0,0 +1,5 @@
1
+ # Metadata is in setup.cfg: Ubuntu 22.04's setuptools (59, what colcon uses
2
+ # on Humble) can't read a [project] table.
3
+ [build-system]
4
+ requires = ["setuptools>=59"]
5
+ build-backend = "setuptools.build_meta"
File without changes
@@ -0,0 +1,42 @@
1
+ [metadata]
2
+ name = lte-launch-test
3
+ version = 0.1.0
4
+ description = ROS 2 launch tests on pytest, a launch_testing replacement on libtmux_easy
5
+ long_description = file: README.md
6
+ long_description_content_type = text/markdown
7
+ url = https://github.com/bigdiehl/lte_launch_test
8
+ author = Spencer Diehl
9
+ author_email = docdiehl@gmail.com
10
+ license = Apache-2.0
11
+ license_files = LICENSE
12
+ keywords = ros2, ros, launch, testing, pytest, launch_testing
13
+ classifiers =
14
+ Development Status :: 4 - Beta
15
+ Framework :: Pytest
16
+ Intended Audience :: Developers
17
+ Programming Language :: Python :: 3
18
+ Programming Language :: Python :: 3.10
19
+ Programming Language :: Python :: 3.12
20
+ Topic :: Software Development :: Testing
21
+
22
+ [options]
23
+ package_dir =
24
+ = src
25
+ packages = find:
26
+ python_requires = >=3.10
27
+ install_requires =
28
+ libtmux-easy>=0.2.0
29
+ pytest>=7.0,<9
30
+ pyyaml
31
+
32
+ [options.packages.find]
33
+ where = src
34
+
35
+ [options.data_files]
36
+ share/ament_index/resource_index/packages = resource/lte_launch_test
37
+ share/lte_launch_test = package.xml
38
+
39
+ [egg_info]
40
+ tag_build =
41
+ tag_date = 0
42
+
@@ -0,0 +1,4 @@
1
+ # Metadata: setup.cfg. This file is for colcon (ament_python) and pybuild.
2
+ from setuptools import setup
3
+
4
+ setup()
@@ -0,0 +1,44 @@
1
+ """ROS 2 launch tests on pytest, with libtmux_easy running the processes.
2
+
3
+ libtmux_easy.testing does the generic work (start, watch, stop, logs,
4
+ --tmux, --keep-open); this adds the ROS parts. Enable with:
5
+
6
+ pytest_plugins = ["lte_launch_test.plugin"] # in conftest.py
7
+ """
8
+
9
+ import os
10
+
11
+ import pytest
12
+
13
+ if "ROS_DISTRO" not in os.environ:
14
+ raise ImportError(
15
+ "lte_launch_test: source ROS 2 first, e.g. source /opt/ros/jazzy/setup.bash"
16
+ )
17
+
18
+ # Imported below, before pytest loads it as a plugin: rewrite its asserts anyway.
19
+ pytest.register_assert_rewrite("libtmux_easy.testing")
20
+
21
+ from libtmux_easy.testing import Processes, arg, check_health, parametrize # noqa: E402
22
+
23
+ from .bag import (
24
+ ALL_SUBSCRIBED,
25
+ bag_counts,
26
+ check_bag,
27
+ wait_for_bag_topics,
28
+ ) # noqa: E402
29
+ from .checks import composable_failure, launch_death # noqa: E402
30
+ from .probe import RosProbe # noqa: E402
31
+
32
+ __all__ = [
33
+ "ALL_SUBSCRIBED",
34
+ "Processes",
35
+ "RosProbe",
36
+ "arg",
37
+ "bag_counts",
38
+ "check_bag",
39
+ "check_health",
40
+ "composable_failure",
41
+ "launch_death",
42
+ "parametrize",
43
+ "wait_for_bag_topics",
44
+ ]
@@ -0,0 +1,52 @@
1
+ """Checks on a 'ros2 bag record' process: it found every topic it was
2
+ asked for, and its bag holds enough messages."""
3
+
4
+ import os
5
+ import re
6
+
7
+ import yaml
8
+
9
+ # rosbag2's recorder, once it has subscribed to every topic it was given.
10
+ ALL_SUBSCRIBED = "All requested topics are subscribed"
11
+ _SUBSCRIBED = re.compile(r"Subscribed to topic '([^']+)'")
12
+
13
+
14
+ def wait_for_bag_topics(procs, name, topics, timeout=30.0):
15
+ """Wait for recorder 'name' to subscribe to all its 'topics'; fails
16
+ naming the ones it hasn't after 'timeout' s."""
17
+ try:
18
+ procs.wait_for(name, ALL_SUBSCRIBED, timeout)
19
+ except AssertionError as exc:
20
+ missing = sorted(set(topics) - set(_SUBSCRIBED.findall(procs.output(name))))
21
+ # Keep the cause: the recorder may have died, not just not found them.
22
+ raise AssertionError(
23
+ f"{name}: not subscribed to {', '.join(missing) or '?'}" f"\n{exc}"
24
+ ) from None
25
+
26
+
27
+ def bag_counts(bag_dir):
28
+ """{topic: messages} from the bag's metadata.yaml, which the recorder
29
+ writes when it stops."""
30
+ with open(os.path.join(bag_dir, "metadata.yaml")) as f:
31
+ info = yaml.safe_load(f)["rosbag2_bagfile_information"]
32
+ return {
33
+ t["topic_metadata"]["name"]: t["message_count"]
34
+ for t in info["topics_with_message_count"]
35
+ }
36
+
37
+
38
+ def check_bag(bag_dir, min_messages):
39
+ """Fail naming each topic with fewer messages than min_messages[topic].
40
+ Stop the recorder first."""
41
+ try:
42
+ counts = bag_counts(bag_dir)
43
+ except FileNotFoundError:
44
+ raise AssertionError(
45
+ f"no {bag_dir}/metadata.yaml: stop the recorder first"
46
+ ) from None
47
+ low = [
48
+ f"{topic}: {counts.get(topic, 0)} messages, want at least {n}"
49
+ for topic, n in min_messages.items()
50
+ if counts.get(topic, 0) < n
51
+ ]
52
+ assert not low, f"bag {bag_dir}:\n " + "\n ".join(low)
@@ -0,0 +1,72 @@
1
+ """fail_on checks for ROS output."""
2
+
3
+ import re
4
+
5
+ # 'ros2 launch' reports a node's death in its output and keeps running.
6
+ _LAUNCH_DIED = re.compile(
7
+ r"\[ERROR\] \[(?P<name>[\w.]+?)(-\d+)?\]: process has died "
8
+ r"\[pid \d+, exit code (?P<code>-?\d+)"
9
+ )
10
+
11
+ # A composable node 'ros2 launch' could not load into its container.
12
+ _LOAD_FAILED = re.compile(
13
+ r"\[ERROR\] \[launch_ros\.actions\.load_composable_nodes\]: "
14
+ r"Failed to load node '(?P<name>[^']+)' of type '(?P<type>[^']+)' "
15
+ r"in container '(?P<container>[^']+)': (?P<reason>.*)"
16
+ )
17
+
18
+ # The container's own log, when it cannot create a component: its logger
19
+ # name, and the plugin only for a missing class.
20
+ _CONTAINER_ERROR = re.compile(
21
+ r"\[ERROR\] .*?\[(?P<container>[^\]]+)\]: (?P<what>Failed to find class with the requested "
22
+ r"plugin name(?: '(?P<plugin>[^']*)')?|Could not find requested "
23
+ r"resource in ament index|Failed to load library)"
24
+ )
25
+
26
+ # Exit codes launch reports for a stop on purpose: SIGINT or SIGTERM, as a
27
+ # signal (-N) or a shell status (128+N). Same semantics as classify() in
28
+ # core_launch's launch_monitor.py (kept apart: this package doesn't import it).
29
+ _STOP_CODES = {-2, -15, 130, 143}
30
+
31
+
32
+ def _is_failure(code):
33
+ """Exit 0 and SIGINT/SIGTERM are not; any other code is."""
34
+ return code != 0 and code not in _STOP_CODES
35
+
36
+
37
+ def launch_death(allow=()):
38
+ """A fail_on check: a node died inside 'ros2 launch'. Exit 0, a stop by
39
+ SIGINT/SIGTERM, and the names in 'allow', are fine."""
40
+ allow = set(allow)
41
+
42
+ def check(line):
43
+ m = _LAUNCH_DIED.search(line)
44
+ if m and _is_failure(int(m["code"])) and m["name"] not in allow:
45
+ return f"node {m['name']} died (exit code {m['code']})"
46
+
47
+ return check
48
+
49
+
50
+ def composable_failure(allow=()):
51
+ """A fail_on check: a composable node failed to load, from 'ros2 launch'
52
+ ("Failed to load node ...") or from the container's own error ("Failed to
53
+ find class", "Could not find requested resource", "Failed to load
54
+ library"). 'allow': node names, types or containers ("/cont" from launch,
55
+ "cont" from the container's log) and plugin names to ignore. The container's
56
+ "Could not find requested resource" line names no node: allow the container."""
57
+ allow = set(allow)
58
+
59
+ def check(line):
60
+ m = _LOAD_FAILED.search(line)
61
+ if m:
62
+ if {m["name"], m["type"], m["container"]} & allow:
63
+ return None
64
+ return (
65
+ f"composable node {m['name']} ({m['type']}) failed to load "
66
+ f"in {m['container']}: {m['reason'].strip()}"
67
+ )
68
+ m = _CONTAINER_ERROR.search(line)
69
+ if m and not {m["plugin"], m["container"]} & allow:
70
+ return f"container error: {m['what']}"
71
+
72
+ return check
@@ -0,0 +1,83 @@
1
+ """The pytest plugin: libtmux_easy.testing, plus
2
+
3
+ - each run its own ROS_DOMAIN_ID (reserved through domain_coordinator, as
4
+ launch_testing_ros does), localhost only
5
+ - lte_pane_setup: every process runs in pytest's rootdir, in this run's
6
+ domain, and writes its ROS logs next to its process log (LTE_LOG_DIR)
7
+ - a RosProbe made in a test is destroyed after it
8
+ - pytest's own ignores (--ignore, collect_ignore, norecursedirs) work
9
+ beside launch_testing 3.4.12, which disables them
10
+ """
11
+
12
+ import os
13
+ import shlex
14
+
15
+ import pytest
16
+ from domain_coordinator import domain_id
17
+
18
+
19
+ # Before rclpy.init(), and given to every process: keep this run's DDS
20
+ # traffic to itself and to this machine. The domain stays reserved (a bound
21
+ # port) until the run ends, so runs at once get different ones.
22
+ if "ROS_DOMAIN_ID" not in os.environ:
23
+ _domain = domain_id()
24
+ os.environ["ROS_DOMAIN_ID"] = str(_domain.__enter__())
25
+ # Localhost only: Humble's switch; Iron and later replaced it.
26
+ _LOCAL = (
27
+ ("ROS_LOCALHOST_ONLY", "1")
28
+ if os.environ.get("ROS_DISTRO") == "humble"
29
+ else ("ROS_AUTOMATIC_DISCOVERY_RANGE", "LOCALHOST")
30
+ )
31
+ os.environ.setdefault(*_LOCAL)
32
+
33
+ from . import probe as _probe
34
+
35
+ pytest_plugins = ["libtmux_easy.testing"]
36
+
37
+
38
+ @pytest.fixture(scope="session")
39
+ def lte_pane_setup(pytestconfig):
40
+ # Processes inherit pytest's environment, ROS and any overlay included:
41
+ # sourcing ROS again would put the underlay first.
42
+ return (
43
+ f"export ROS_DOMAIN_ID={os.environ['ROS_DOMAIN_ID']}"
44
+ f" {_LOCAL[0]}={os.environ[_LOCAL[0]]}"
45
+ ' ROS_LOG_DIR="$LTE_LOG_DIR/ros"'
46
+ f" && cd {shlex.quote(str(pytestconfig.rootpath))}"
47
+ )
48
+
49
+
50
+ @pytest.hookimpl(hookwrapper=True)
51
+ def pytest_runtest_call(item):
52
+ """A RosProbe made in a test is destroyed after it, 'with' or not."""
53
+ _probe._test_probes = []
54
+ try:
55
+ yield
56
+ finally:
57
+ made, _probe._test_probes = _probe._test_probes, None
58
+ for probe in made:
59
+ probe.destroy()
60
+
61
+
62
+ # Workaround: launch_testing 3.4.12 (Jazzy's apt package) disables pytest's
63
+ # --ignore, collect_ignore and norecursedirs for every project with ROS sourced.
64
+ #
65
+ # pytest asks pytest_ignore_collect(path) of every plugin before collecting a
66
+ # path; the first answer that isn't None wins. pytest's own ignore rules are
67
+ # its default implementation of that hook. launch_testing 3.4.12 registers one
68
+ # with tryfirst=True that returns False ("don't ignore") for everything but
69
+ # launch files, so pytest's default is never asked. (Fixed upstream: it returns
70
+ # None now. Seen in CI: examples/failing/ collected despite norecursedirs.)
71
+ #
72
+ # This hook is tryfirst too, and registered later (conftest pytest_plugins load
73
+ # after setuptools entry points), so pluggy calls it first. It asks pytest's
74
+ # default: True (ignore) wins; else None, and the other hooks decide as before.
75
+ # _pytest.main is private, but this function has had this signature since
76
+ # pytest 7. Remove once Jazzy ships the fix: test/test_plugin_collect.py checks
77
+ # both the bug and this workaround.
78
+ @pytest.hookimpl(tryfirst=True)
79
+ def pytest_ignore_collect(collection_path, config):
80
+ """pytest's own ignores first: --ignore, collect_ignore, norecursedirs."""
81
+ from _pytest.main import pytest_ignore_collect as pytest_default
82
+
83
+ return True if pytest_default(collection_path=collection_path, config=config) else None
@@ -0,0 +1,273 @@
1
+ """RosProbe: an rclpy node the tests talk to the system with. Nothing here
2
+ knows about tmux or subprocesses: libtmux_easy's check_health() is how a
3
+ wait fails fast on a death."""
4
+
5
+ import itertools
6
+ import os
7
+ import time
8
+
9
+ import rclpy
10
+ import rclpy.time
11
+ import yaml
12
+ from rcl_interfaces.msg import ParameterType
13
+ from rcl_interfaces.srv import GetParameters, ListParameters, SetParameters
14
+ from rclpy.parameter import Parameter, parameter_value_to_python
15
+ from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy
16
+
17
+ from libtmux_easy.testing import check_health
18
+
19
+
20
+ # Matches any publisher: BEST_EFFORT takes RELIABLE and BEST_EFFORT ones,
21
+ # VOLATILE takes VOLATILE and TRANSIENT_LOCAL ones (e.g. a lidar's sensor QoS).
22
+ ANY_PUBLISHER = QoSProfile(
23
+ depth=10,
24
+ reliability=ReliabilityPolicy.BEST_EFFORT,
25
+ durability=DurabilityPolicy.VOLATILE,
26
+ )
27
+
28
+
29
+ def ros_init(log_dir=None):
30
+ """rclpy.init() once per run. 'log_dir': where this process's ROS logs
31
+ go (ROS_LOG_DIR), read at the first init only."""
32
+ if not rclpy.ok():
33
+ if log_dir:
34
+ os.environ["ROS_LOG_DIR"] = str(log_dir)
35
+ rclpy.init()
36
+
37
+
38
+ def _srv(node, service):
39
+ """'talker', 'get_parameters' -> '/talker/get_parameters'."""
40
+ return f"/{node.strip('/')}/{service}"
41
+
42
+
43
+ _test_probes = None # while a test runs (plugin.py): the RosProbes it made
44
+ _count = itertools.count() # node names unique within the process
45
+
46
+
47
+ class RosProbe:
48
+ """An rclpy node the tests use to poke the system. Every wait calls
49
+ check_health(), so a dead process fails it now, not at the timeout.
50
+
51
+ cls.probe = RosProbe(log_dir=cls.procs.log_dir) # setup_class; destroy() in teardown
52
+ with RosProbe() as probe: # in a test
53
+
54
+ One made in a test is destroyed after it, 'with' or not. rclpy.init()
55
+ is done by the first RosProbe of the run; 'log_dir': its ROS logs go to
56
+ '<log_dir>/ros_probe' (that first RosProbe's only)."""
57
+
58
+ def __init__(self, name="test_probe", log_dir=None):
59
+ ros_init(os.path.join(log_dir, "ros_probe") if log_dir else None)
60
+ n = next(_count)
61
+ self.node = rclpy.create_node(f"{name}_{os.getpid()}" + (f"_{n}" if n else ""))
62
+ self._tf = None # (Buffer, TransformListener), on the first wait_for_transform
63
+ if _test_probes is not None:
64
+ _test_probes.append(self)
65
+
66
+ def __enter__(self):
67
+ return self
68
+
69
+ def __exit__(self, *exc):
70
+ self.destroy()
71
+
72
+ def destroy(self):
73
+ """Destroy the node; a second call does nothing."""
74
+ if self.node is not None:
75
+ if self._tf:
76
+ self._tf[1].unregister()
77
+ self.node.destroy_node()
78
+ self.node = None
79
+
80
+ def spin_until(self, done, timeout, what):
81
+ deadline = time.monotonic() + timeout
82
+ while time.monotonic() < deadline:
83
+ rclpy.spin_once(self.node, timeout_sec=0.1)
84
+ check_health()
85
+ result = done()
86
+ if result:
87
+ return result
88
+ raise AssertionError(f"timed out after {timeout}s waiting for {what}")
89
+
90
+ def nodes(self):
91
+ return set(self.node.get_node_names())
92
+
93
+ def wait_for_nodes(self, names, timeout=20.0):
94
+ self.spin_until(
95
+ lambda: set(names) <= self.nodes(), timeout, f"nodes {sorted(names)}"
96
+ )
97
+
98
+ def wait_for_nodes_gone(self, names, timeout=10.0):
99
+ self.spin_until(
100
+ lambda: not set(names) & self.nodes(),
101
+ timeout,
102
+ f"nodes {sorted(names)} to leave the graph",
103
+ )
104
+
105
+ def topic_types(self):
106
+ return dict(self.node.get_topic_names_and_types())
107
+
108
+ def wait_for_topics(self, names, timeout=10.0):
109
+ """The topic types, once every topic in 'names' has a type."""
110
+ if not names:
111
+ return self.topic_types()
112
+ try:
113
+
114
+ def have_all():
115
+ types = self.topic_types()
116
+ return set(names) <= set(types) and types
117
+
118
+ return self.spin_until(have_all, timeout, f"topics {sorted(names)}")
119
+ except AssertionError as exc:
120
+ raise AssertionError(f"{exc}; have {sorted(self.topic_types())}") from None
121
+
122
+ def wait_for_messages(
123
+ self, msg_type, topic, count=1, timeout=5.0, qos=ANY_PUBLISHER
124
+ ):
125
+ received = []
126
+ sub = self.node.create_subscription(msg_type, topic, received.append, qos)
127
+ try:
128
+ self.spin_until(
129
+ lambda: len(received) >= count, timeout, f"{count} on {topic}"
130
+ )
131
+ return received[:count]
132
+ finally:
133
+ self.node.destroy_subscription(sub)
134
+
135
+ def rate(self, msg_type, topic, seconds=2.0, qos=ANY_PUBLISHER):
136
+ """Messages per second on 'topic', counted for 'seconds'."""
137
+ received = []
138
+ sub = self.node.create_subscription(msg_type, topic, received.append, qos)
139
+ try:
140
+ self.spin_until(lambda: received, 5.0, f"a message on {topic}")
141
+ received.clear()
142
+ end = time.monotonic() + seconds
143
+ self.spin_until(lambda: time.monotonic() >= end, seconds + 1, "the count")
144
+ return len(received) / seconds
145
+ finally:
146
+ self.node.destroy_subscription(sub)
147
+
148
+ def services(self):
149
+ """{service: [types]} in the graph."""
150
+ return dict(self.node.get_service_names_and_types())
151
+
152
+ def wait_for_services(self, names, timeout=10.0):
153
+ """The service types, once every service in 'names' is in the graph."""
154
+
155
+ def have_all():
156
+ types = self.services()
157
+ return set(names) <= set(types) and types
158
+
159
+ try:
160
+ return self.spin_until(have_all, timeout, f"services {sorted(names)}")
161
+ except AssertionError as exc:
162
+ raise AssertionError(f"{exc}; have {sorted(self.services())}") from None
163
+
164
+ def call(self, srv_type, name, request=None, timeout=5.0, **fields):
165
+ """Call service 'name' once it is up; the reply. The request, or
166
+ its fields: call(AddTwoInts, "add_two_ints", a=2, b=40)."""
167
+ if request is None:
168
+ request = srv_type.Request(**fields)
169
+ elif fields:
170
+ raise TypeError("call(): a request or its fields, not both")
171
+ client = self.node.create_client(srv_type, name)
172
+ try:
173
+ self.spin_until(client.service_is_ready, timeout, f"service {name}")
174
+ future = client.call_async(request)
175
+ self.spin_until(future.done, timeout, f"a reply from {name}")
176
+ return future.result()
177
+ finally:
178
+ self.node.destroy_client(client)
179
+
180
+ def wait_for_transform(self, target, source, timeout=10.0):
181
+ """The latest transform from frame 'source' to frame 'target' (a
182
+ TransformStamped), once tf has it: wait_for_transform("map",
183
+ "base_link") is base_link's pose in map. Chains through the tree.
184
+ The probe listens to tf from its first call on; static transforms
185
+ are latched, so they arrive whenever it starts."""
186
+ if self._tf is None:
187
+ from tf2_ros import (
188
+ Buffer,
189
+ TransformListener,
190
+ ) # only tests that use tf need it
191
+
192
+ buffer = Buffer()
193
+ self._tf = (buffer, TransformListener(buffer, self.node))
194
+ buffer = self._tf[0]
195
+
196
+ def lookup():
197
+ if buffer.can_transform(target, source, rclpy.time.Time()):
198
+ return buffer.lookup_transform(target, source, rclpy.time.Time())
199
+
200
+ try:
201
+ return self.spin_until(lookup, timeout, f"transform {source} -> {target}")
202
+ except AssertionError as exc:
203
+ frames = yaml.safe_load(buffer.all_frames_as_yaml()) or {}
204
+ have = ", ".join(
205
+ sorted(f"{f['parent']} -> {child}" for child, f in frames.items())
206
+ )
207
+ raise AssertionError(f"{exc}; have {have or 'no transforms'}") from None
208
+
209
+ # Another node's parameters, through its parameter services. 'node': its
210
+ # name, "talker" or "/ns/talker".
211
+
212
+ def get_params(self, node, names, timeout=5.0):
213
+ """{name: value} (Python values); fails naming any 'node' hasn't set."""
214
+ reply = self.call(
215
+ GetParameters,
216
+ _srv(node, "get_parameters"),
217
+ timeout=timeout,
218
+ names=list(names),
219
+ )
220
+ missing = [
221
+ n
222
+ for n, v in zip(names, reply.values)
223
+ if v.type == ParameterType.PARAMETER_NOT_SET
224
+ ]
225
+ assert not missing, f"{node}: no parameter {', '.join(missing)}"
226
+ return {n: parameter_value_to_python(v) for n, v in zip(names, reply.values)}
227
+
228
+ def get_param(self, node, name, timeout=5.0):
229
+ return self.get_params(node, [name], timeout)[name]
230
+
231
+ def set_params(self, node, values, timeout=5.0):
232
+ """Set {name: value} on 'node', each on its own; fails with the
233
+ node's reason for each one it rejects."""
234
+ msgs = [Parameter(n, value=v).to_parameter_msg() for n, v in values.items()]
235
+ reply = self.call(
236
+ SetParameters,
237
+ _srv(node, "set_parameters"),
238
+ timeout=timeout,
239
+ parameters=msgs,
240
+ )
241
+ rejected = [
242
+ f"{n}: {r.reason or 'rejected'}"
243
+ for n, r in zip(values, reply.results)
244
+ if not r.successful
245
+ ]
246
+ assert not rejected, f"{node} rejected " + "; ".join(rejected)
247
+
248
+ def set_param(self, node, name, value, timeout=5.0):
249
+ self.set_params(node, {name: value}, timeout)
250
+
251
+ def list_params(self, node, timeout=5.0):
252
+ """The names of 'node''s parameters."""
253
+ reply = self.call(
254
+ ListParameters, _srv(node, "list_parameters"), timeout=timeout
255
+ )
256
+ return sorted(reply.result.names)
257
+
258
+ def publish_until(self, msg_type, topic, msg, done, timeout=5.0, what=None):
259
+ """Publish 'msg' every half second until done() -- a fresh publisher's
260
+ first messages can go out before a subscriber has matched it."""
261
+ pub = self.node.create_publisher(msg_type, topic, 10)
262
+ last = [0.0]
263
+
264
+ def step():
265
+ if time.monotonic() - last[0] > 0.5:
266
+ pub.publish(msg)
267
+ last[0] = time.monotonic()
268
+ return done()
269
+
270
+ try:
271
+ return self.spin_until(step, timeout, what or f"a reaction to {topic}")
272
+ finally:
273
+ self.node.destroy_publisher(pub)
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: lte-launch-test
3
+ Version: 0.1.0
4
+ Summary: ROS 2 launch tests on pytest, a launch_testing replacement on libtmux_easy
5
+ Home-page: https://github.com/bigdiehl/lte_launch_test
6
+ Author: Spencer Diehl
7
+ Author-email: docdiehl@gmail.com
8
+ License: Apache-2.0
9
+ Keywords: ros2,ros,launch,testing,pytest,launch_testing
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Framework :: Pytest
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: libtmux-easy>=0.2.0
21
+ Requires-Dist: pytest<9,>=7.0
22
+ Requires-Dist: pyyaml
23
+ Dynamic: license-file
24
+
25
+ # lte_launch_test
26
+
27
+ ROS 2 launch tests on pytest: a replacement for `ros2 launch_test` /
28
+ `launch_testing`. [libtmux_easy](https://github.com/nasa-jpl/libtmux_easy)'s pytest plugin runs the
29
+ processes (subprocesses by default, tmux with `--tmux`); this package adds
30
+ the ROS parts. libtmux_easy stays ROS-agnostic.
31
+
32
+ Processes are shell commands; a launch file is one too (`ros2 launch
33
+ file.py`). Design and `launch_testing` parity: [docs/](docs/README.md).
34
+
35
+ ROS 2 Humble and Jazzy.
36
+
37
+ ## Install
38
+
39
+ | how | |
40
+ |---|---|
41
+ | pip | `pip install lte-launch-test` in a venv made with `--system-site-packages` (it needs ROS's `rclpy`) |
42
+ | `.deb` (Ubuntu 24.04) | from the GitHub Releases, with libtmux_easy's |
43
+ | colcon | in your workspace's `src/`, with `package.xml` |
44
+
45
+ Details: [docs/packaging.md](docs/packaging.md).
46
+
47
+ ## Quick start
48
+
49
+ ```bash
50
+ cd examples
51
+ ./run.sh test_01_topic.py -v # start here; numbered simplest first
52
+ LTE_DEMO_CRASH=1 ./run.sh test_09_tour.py -v # a node dies mid-scenario
53
+ ./run.sh # all of them
54
+ ./run.sh --tmux # the dashboard; --no-attach: detached tmux
55
+ ./run.sh --keep-open # leave every node running after the run
56
+ ./run.sh failing # each fails on purpose
57
+ ```
58
+
59
+ `run.sh` uses ROS as sourced (else `/opt/ros/$ROS_DISTRO`, default Jazzy), puts `src/` on the path, and uses
60
+ `../libtmux_easy/src` if libtmux_easy isn't installed.
61
+
62
+ ## Use
63
+
64
+ ```python
65
+ # conftest.py
66
+ pytest_plugins = ["lte_launch_test.plugin"]
67
+ ```
68
+
69
+ | name | what |
70
+ |---|---|
71
+ | `lte_launch_test.plugin` | a per-run `ROS_DOMAIN_ID` (reserved via `domain_coordinator`) + localhost discovery; every process inherits pytest's environment (ROS and overlays), runs in the rootdir, logs ROS to `$LTE_LOG_DIR/ros`; a `RosProbe` made in a test is destroyed after it |
72
+ | `RosProbe(log_dir=)` | rclpy node (does `rclpy.init()` first; its ROS logs in `<log_dir>/ros_probe`); `with RosProbe() as probe:` in a test, or `RosProbe()` in `setup_class` and `destroy()` in teardown; subscribes with QoS that matches any publisher; waits (`wait_for_nodes`, `wait_for_topics`, `wait_for_services`, `wait_for_messages`, `rate`, `call`, `publish_until`) call `check_health()`, so a death fails them at once |
73
+ | `RosProbe` services, params | `call(Srv, name, a=2, b=40)` (or a request); another node's parameters: `get_param(node, name)`, `get_params`, `set_param(node, name, value)`, `set_params(node, {...})` (fails with the node's reason if rejected), `list_params(node)` |
74
+ | `RosProbe` tf | `wait_for_transform(target, source)`: a `TransformStamped` once tf has it (chains through the tree); the failure lists the transforms it has |
75
+ | `launch_death(allow=)` | `fail_on` check: a node died inside `ros2 launch` (non-zero exit; SIGINT/SIGTERM and exit 0 are fine) |
76
+ | `composable_failure(allow=)` | `fail_on` check: a composable node failed to load (launch's `Failed to load node`, or the container's `Failed to find class` / `Could not find requested resource` / `Failed to load library`) |
77
+ | `wait_for_bag_topics(procs, name, topics, timeout)` | wait for `ros2 bag record` process `name` to subscribe to all its topics; fails naming the missing ones |
78
+ | `check_bag(bag_dir, min_messages)` | after the recorder stopped: fail on each topic below its `{topic: n}` count (from `metadata.yaml`); `bag_counts()`: `{topic: n}` |
79
+ | `Processes`, `check_health`, `arg`, `parametrize` | re-exported from `libtmux_easy.testing` |
80
+
81
+ ## colcon
82
+
83
+ Verified with `colcon test` on Jazzy, for both build types. Put
84
+ `test/conftest.py` (`pytest_plugins = [...]`) next to the tests; libtmux_easy and
85
+ lte_launch_test must be importable (installed, or on `PYTHONPATH`).
86
+
87
+ | build type | add |
88
+ |---|---|
89
+ | `ament_python` | nothing: colcon runs pytest on `test/` |
90
+ | `ament_cmake` | `find_package(ament_cmake_pytest REQUIRED)` and `ament_add_pytest_test(test_talker test/test_talker.py TIMEOUT 120)` |
91
+
92
+ ## Layout
93
+
94
+ | path | what |
95
+ |---|---|
96
+ | `src/lte_launch_test/` | the package |
97
+ | `docs/` | design, `launch_testing` parity, packaging, [future development](docs/FUTURE_DEVELOPMENT.md) |
98
+ | `examples/` | numbered example tests, simplest first; `failing/` fails on purpose. See [examples/](examples/README.md) |
99
+ | `test/` | `test/run.sh`: every failure check, unit and live (launch deaths, container crash, composable load errors, bag topics/counts, probe waits, services, params, tf), and that `examples/failing` fails as documented |
100
+ | `debian/`, `package.xml`, `setup.cfg` | the `.deb`, colcon, pip |
101
+ | `.github/workflows/` | CI (Humble, Jazzy), release |
102
+
103
+ ## Notes
104
+
105
+ - A failed test stops its class's processes at once (SIGINT, escalating;
106
+ `on_abort=` first, if given) and skips the rest of the class.
107
+ `--keep-open` keeps them instead.
108
+ - Logs: each process's output and ROS logs go to pytest's tmp dir, printed
109
+ at the end as `process logs: <dir>`.
110
+ - A node that should exit 0 on SIGINT must catch `KeyboardInterrupt` /
111
+ `ExternalShutdownException` around `rclpy.spin()` (see `examples/nodes/counter_node.py`).
112
+ - A stopped node's name can linger in the graph briefly. Give per-test
113
+ nodes names of their own (`-r __node:=...`).
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ package.xml
5
+ pyproject.toml
6
+ setup.cfg
7
+ setup.py
8
+ resource/lte_launch_test
9
+ src/lte_launch_test/__init__.py
10
+ src/lte_launch_test/bag.py
11
+ src/lte_launch_test/checks.py
12
+ src/lte_launch_test/plugin.py
13
+ src/lte_launch_test/probe.py
14
+ src/lte_launch_test.egg-info/PKG-INFO
15
+ src/lte_launch_test.egg-info/SOURCES.txt
16
+ src/lte_launch_test.egg-info/dependency_links.txt
17
+ src/lte_launch_test.egg-info/requires.txt
18
+ src/lte_launch_test.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ libtmux-easy>=0.2.0
2
+ pytest<9,>=7.0
3
+ pyyaml
@@ -0,0 +1 @@
1
+ lte_launch_test