pytest-ros 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,67 @@
1
+ # This workflow uses actions that are not certified by GitHub.
2
+ # They are provided by a third-party and are governed by
3
+ # separate terms of service, privacy policy, and support
4
+ # documentation.
5
+
6
+ # GitHub recommends pinning actions to a commit SHA.
7
+ # To get a newer version, you will need to update the SHA.
8
+ # You can also reference a tag or branch, but the action may change without warning.
9
+
10
+ name: Upload Python Package
11
+
12
+ on:
13
+ release:
14
+ types: [published]
15
+ workflow_dispatch:
16
+
17
+ permissions:
18
+ contents: read
19
+
20
+ jobs:
21
+ release-build:
22
+ runs-on: ubuntu-latest
23
+
24
+ steps:
25
+ - uses: actions/checkout@v6
26
+
27
+ - uses: actions/setup-python@v5
28
+ with:
29
+ python-version: "3.x"
30
+
31
+ - name: Build release distributions
32
+ run: |
33
+ # NOTE: put your own distribution build steps here.
34
+ python -m pip install build
35
+ python -m build
36
+
37
+ - name: Upload distributions
38
+ uses: actions/upload-artifact@v4
39
+ with:
40
+ name: release-dists
41
+ path: dist/
42
+
43
+ pypi-publish:
44
+ runs-on: ubuntu-latest
45
+
46
+ needs:
47
+ - release-build
48
+
49
+ permissions:
50
+ # IMPORTANT: this permission is mandatory for trusted publishing
51
+ id-token: write
52
+
53
+ # Dedicated environments with protections for publishing are strongly recommended.
54
+ environment:
55
+ name: pypi
56
+ # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status:
57
+ # url: https://pypi.org/p/YOURPROJECT
58
+
59
+ steps:
60
+ - name: Retrieve release distributions
61
+ uses: actions/download-artifact@v5
62
+ with:
63
+ name: release-dists
64
+ path: dist/
65
+
66
+ - name: Publish release distributions to PyPI
67
+ uses: pypa/gh-action-pypi-publish@v1.14.3
@@ -0,0 +1,209 @@
1
+ *_version.py
2
+
3
+ # Byte-compiled / optimized / DLL files
4
+ __pycache__/
5
+ *.py[codz]
6
+ *$py.class
7
+
8
+ # C extensions
9
+ *.so
10
+
11
+ # Distribution / packaging
12
+ .Python
13
+ build/
14
+ develop-eggs/
15
+ dist/
16
+ downloads/
17
+ eggs/
18
+ .eggs/
19
+ lib/
20
+ lib64/
21
+ parts/
22
+ sdist/
23
+ var/
24
+ wheels/
25
+ share/python-wheels/
26
+ *.egg-info/
27
+ .installed.cfg
28
+ *.egg
29
+ MANIFEST
30
+
31
+ # PyInstaller
32
+ # Usually these files are written by a python script from a template
33
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
34
+ *.manifest
35
+ *.spec
36
+
37
+ # Installer logs
38
+ pip-log.txt
39
+ pip-delete-this-directory.txt
40
+
41
+ # Unit test / coverage reports
42
+ htmlcov/
43
+ .tox/
44
+ .nox/
45
+ .coverage
46
+ .coverage.*
47
+ .cache
48
+ nosetests.xml
49
+ coverage.xml
50
+ *.cover
51
+ *.py.cover
52
+ .hypothesis/
53
+ .pytest_cache/
54
+ cover/
55
+
56
+ # Translations
57
+ *.mo
58
+ *.pot
59
+
60
+ # Django stuff:
61
+ *.log
62
+ local_settings.py
63
+ db.sqlite3
64
+ db.sqlite3-journal
65
+
66
+ # Flask stuff:
67
+ instance/
68
+ .webassets-cache
69
+
70
+ # Scrapy stuff:
71
+ .scrapy
72
+
73
+ # Sphinx documentation
74
+ docs/_build/
75
+
76
+ # PyBuilder
77
+ .pybuilder/
78
+ target/
79
+
80
+ # Jupyter Notebook
81
+ .ipynb_checkpoints
82
+
83
+ # IPython
84
+ profile_default/
85
+ ipython_config.py
86
+
87
+ # pyenv
88
+ # For a library or package, you might want to ignore these files since the code is
89
+ # intended to run in multiple environments; otherwise, check them in:
90
+ # .python-version
91
+
92
+ # pipenv
93
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
94
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
95
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
96
+ # install all needed dependencies.
97
+ #Pipfile.lock
98
+
99
+ # UV
100
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
101
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
102
+ # commonly ignored for libraries.
103
+ #uv.lock
104
+
105
+ # poetry
106
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
107
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
108
+ # commonly ignored for libraries.
109
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
110
+ #poetry.lock
111
+ #poetry.toml
112
+
113
+ # pdm
114
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
115
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
116
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
117
+ #pdm.lock
118
+ #pdm.toml
119
+ .pdm-python
120
+ .pdm-build/
121
+
122
+ # pixi
123
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
124
+ #pixi.lock
125
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
126
+ # in the .venv directory. It is recommended not to include this directory in version control.
127
+ .pixi
128
+
129
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
130
+ __pypackages__/
131
+
132
+ # Celery stuff
133
+ celerybeat-schedule
134
+ celerybeat.pid
135
+
136
+ # SageMath parsed files
137
+ *.sage.py
138
+
139
+ # Environments
140
+ .env
141
+ .envrc
142
+ .venv
143
+ env/
144
+ venv/
145
+ ENV/
146
+ env.bak/
147
+ venv.bak/
148
+
149
+ # Spyder project settings
150
+ .spyderproject
151
+ .spyproject
152
+
153
+ # Rope project settings
154
+ .ropeproject
155
+
156
+ # mkdocs documentation
157
+ /site
158
+
159
+ # mypy
160
+ .mypy_cache/
161
+ .dmypy.json
162
+ dmypy.json
163
+
164
+ # Pyre type checker
165
+ .pyre/
166
+
167
+ # pytype static type analyzer
168
+ .pytype/
169
+
170
+ # Cython debug symbols
171
+ cython_debug/
172
+
173
+ # PyCharm
174
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
175
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
176
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
177
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
178
+ #.idea/
179
+
180
+ # Abstra
181
+ # Abstra is an AI-powered process automation framework.
182
+ # Ignore directories containing user credentials, local state, and settings.
183
+ # Learn more at https://abstra.io/docs
184
+ .abstra/
185
+
186
+ # Visual Studio Code
187
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
188
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
189
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
190
+ # you could uncomment the following to ignore the entire vscode folder
191
+ # .vscode/
192
+
193
+ # Ruff stuff:
194
+ .ruff_cache/
195
+
196
+ # PyPI configuration file
197
+ .pypirc
198
+
199
+ # Cursor
200
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
201
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
202
+ # refer to https://docs.cursor.com/context/ignore-files
203
+ .cursorignore
204
+ .cursorindexingignore
205
+
206
+ # Marimo
207
+ marimo/_static/
208
+ marimo/_lsp/
209
+ __marimo__/
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.5
2
+ Name: pytest-ros
3
+ Version: 0.1.0
4
+ Summary: A pytest plugin for integration testing of ROS 2 systems
5
+ Author-email: Sean Fish <seanfish@gatech.edu>
6
+ License-Expression: MIT
7
+ Classifier: Framework :: Pytest
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Topic :: Software Development :: Testing
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: pytest>=7.0.0
12
+ Requires-Dist: pyyaml
13
+ Requires-Dist: rclpy
14
+ Provides-Extra: dev
15
+ Requires-Dist: black; extra == 'dev'
16
+ Requires-Dist: hatch; extra == 'dev'
17
+ Requires-Dist: pytest-cov; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # pytest-ros
21
+
22
+ A Pytest plugin to provide fixtures and utilities for testing of ROS 2 systems.
23
+
24
+ ## Features
25
+
26
+ - *Automated Lifecycle Management*: Automatically initialize and clean up launched programs with some additional safeguards against rogue processes, though not guaranteed
27
+
28
+ - *Fixtures*: Included fixtures for launching ROS processes such as launch files and for monitoring ROS topics
29
+
30
+
31
+ ## Installation
32
+
33
+ To install `pytest-ros`:
34
+
35
+ ```
36
+ pip install .
37
+ ```
38
+
39
+ This plugin has an external undeclared dependency on ROS 2 `rclpy`. Make sure ROS 2 is installed and sourced.
40
+
41
+ ## Usage
42
+
43
+ ### Running Tests
44
+
45
+ Execute tests normally with `pytest`.
46
+
47
+
@@ -0,0 +1,28 @@
1
+ # pytest-ros
2
+
3
+ A Pytest plugin to provide fixtures and utilities for testing of ROS 2 systems.
4
+
5
+ ## Features
6
+
7
+ - *Automated Lifecycle Management*: Automatically initialize and clean up launched programs with some additional safeguards against rogue processes, though not guaranteed
8
+
9
+ - *Fixtures*: Included fixtures for launching ROS processes such as launch files and for monitoring ROS topics
10
+
11
+
12
+ ## Installation
13
+
14
+ To install `pytest-ros`:
15
+
16
+ ```
17
+ pip install .
18
+ ```
19
+
20
+ This plugin has an external undeclared dependency on ROS 2 `rclpy`. Make sure ROS 2 is installed and sourced.
21
+
22
+ ## Usage
23
+
24
+ ### Running Tests
25
+
26
+ Execute tests normally with `pytest`.
27
+
28
+
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "pytest-ros"
3
+ dynamic = ["version"]
4
+ description = "A pytest plugin for integration testing of ROS 2 systems"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ authors = [
9
+ { name = "Sean Fish", email = "seanfish@gatech.edu" },
10
+ ]
11
+ classifiers = [
12
+ "Framework :: Pytest",
13
+ "Programming Language :: Python :: 3",
14
+ "Topic :: Software Development :: Testing",
15
+ ]
16
+ dependencies = [
17
+ "pytest>=7.0.0",
18
+ "rclpy",
19
+ "pyyaml",
20
+ ]
21
+
22
+ [build-system]
23
+ requires = ["hatchling", "hatch-vcs"]
24
+ build-backend = "hatchling.build"
25
+
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "hatch",
30
+ "black",
31
+ "pytest-cov",
32
+ ]
33
+
34
+ [project.entry-points."pytest11"]
35
+ # This registers the plugin with pytest
36
+ pytest_ros = "pytest_ros.plugin"
37
+
38
+ [tool.hatch.version]
39
+ source = "vcs"
40
+
41
+ [tool.hatch.build.hooks.vcs]
42
+ version-file = "src/pytest_ros/_version.py"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/pytest_ros"]
46
+
47
+ [tool.hatch.metadata]
48
+ allow-direct-references = true
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+ log_cli = true
53
+ log_cli_level = "INFO"
@@ -0,0 +1,5 @@
1
+ try:
2
+ import rclpy
3
+ RCLPY_AVAILABLE = True
4
+ except ImportError:
5
+ RCLPY_AVAILABLE = False
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,387 @@
1
+ import time
2
+ import threading
3
+
4
+ from pytest_ros import RCLPY_AVAILABLE
5
+ if RCLPY_AVAILABLE:
6
+ import rclpy
7
+ from rclpy.node import Node
8
+ from rclpy.executors import SingleThreadedExecutor
9
+
10
+
11
+ import logging
12
+
13
+
14
+ class EpisodeMonitor:
15
+ """
16
+ A class to monitor test episodes, enabling waiting for start conditions and monitoring for
17
+ termination conditions, both success and failure.
18
+
19
+ The conditions are defined as ROS topics with either a requirement for a message to be received
20
+ or a specific value to be met.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ name: str,
26
+ timeout: float | None = None
27
+ ):
28
+
29
+ self.name = name
30
+ self._logger = logging.getLogger(f'EpisodeMonitor.{self.name}')
31
+
32
+ self._conditions = {} # dict of condition name to its 'value' (True/False/None) and 'interface' (e.g. subscriber or service client)
33
+ self._statuses = {}
34
+
35
+ # Monitor start time
36
+ self._start_time = time.time()
37
+ self._timeout = timeout
38
+
39
+ # Initialize the internal ROS 2 node
40
+ self.node = Node(name)
41
+
42
+ # Setup threading and execution infrastructure
43
+ self._stop_event = threading.Event()
44
+ self._is_running = threading.Event()
45
+
46
+ self._executor = SingleThreadedExecutor()
47
+ self._executor.add_node(self.node)
48
+
49
+ self._spin_thread = threading.Thread(target=self._run, daemon=True)
50
+ self._spin_thread.start()
51
+
52
+ if not self._is_running.wait(timeout=2.0):
53
+ raise RuntimeError("ROS 2 background executor failed to start.")
54
+
55
+ # Create default statuses
56
+ self.register_condition(condition_name='episode_timeout')
57
+ self.register_status(status_name='timeout', conditions=['episode_timeout'], trigger='ANY') # Evaluates to True if any timeouts occur
58
+ self.register_status(status_name='complete', conditions=['timeout'], trigger='ANY') # A special status that can be triggered by any condition or externally to indicate episode completion
59
+
60
+ def set_timeout(self, timeout: float):
61
+ """Sets a new timeout for the episode. If the timeout is breached, the 'timeout' status will be triggered."""
62
+ self._start_time = time.time() # Reset start time whenever timeout is set
63
+ self._timeout = timeout
64
+ self._logger.info(f"Episode timeout set to {timeout} seconds from time {self._start_time}.")
65
+
66
+
67
+ def register_condition(self, condition_name):
68
+ """
69
+ Registers a simple condition that can be manually set to True or False.
70
+
71
+ Parameters:
72
+ condition_name (str): The name of the condition.
73
+ """
74
+ self._conditions[condition_name] = {'value': None, 'interface': 'manual'} # Initialize condition value as None
75
+
76
+ def get_condition(self, condition_name):
77
+ """
78
+ Retrieves the current value of a condition.
79
+
80
+ Parameters:
81
+ condition_name (str): The name of the condition.
82
+
83
+ Returns:
84
+ bool: The current value of the condition, or None if the condition is not registered.
85
+ """
86
+ condition_info = self._conditions.get(condition_name)
87
+ if condition_info:
88
+ return condition_info['value']
89
+ return None
90
+
91
+ def register_topic_condition(self, condition_name, topic_name, msg_type, value_check=None):
92
+ """
93
+ Registers a condition based on a ROS topic.
94
+ Conditions can be True, False, or None (not yet received).
95
+
96
+ Parameters:
97
+ condition_name (str): The name of the condition.
98
+ topic_name (str): The name of the ROS topic to subscribe to.
99
+ msg_type: The ROS message type to expect (e.g., std_msgs.msg.String).
100
+ value_check (callable, optional): A function that takes a message and returns True if it meets the desired condition. If None, any message will suffice.
101
+ """
102
+ self._conditions[condition_name] = {'value': None, 'interface': None} # Initialize condition value as None
103
+ self._conditions[condition_name]['interface'] = self._setup_subscription(
104
+ condition_name=condition_name,
105
+ topic_name=topic_name,
106
+ msg_type=msg_type,
107
+ value_check=value_check
108
+ )
109
+
110
+
111
+
112
+ def register_service_condition(self,
113
+ condition_name,
114
+ service_name,
115
+ srv_type,
116
+ srv_req,
117
+ value_check=None,
118
+ period: float = 1.0,
119
+ start_on_status: str = None,
120
+ timeout: float = None,
121
+ timeout_is_fatal: bool = False
122
+ ):
123
+ """
124
+ Registers a condition based on a ROS service.
125
+
126
+ Parameters:
127
+ condition_name (str): The name of the condition.
128
+ service_name (str): The name of the ROS service to call.
129
+ srv_type: The ROS service type to expect (e.g., std_srvs.srv.Trigger).
130
+ srv_req (srv_type.Request or callable): The request object or a function that returns the request object for the service call.
131
+ value_check (callable, optional): A function that takes a service response and returns True if it meets the desired condition. If None, any response will suffice.
132
+ period (float): How often to call the service in seconds.
133
+ start_on_status (str, optional): If provided, the service calls will only start once this status is triggered.
134
+ timeout (float, optional): Maximum time to keep trying the service call before giving up. If None, it will keep trying indefinitely.
135
+ timeout_is_fatal (bool, optional): If True, the timeout will be reported as an episode timeout.
136
+ """
137
+ self._conditions[condition_name] = {'value': None, 'interface': None} # Initialize condition value as None
138
+ if timeout is not None:
139
+ self._conditions[f'{condition_name}_timeout'] = {'value': False, 'interface': None}
140
+ if timeout_is_fatal:
141
+ self.add_condition_to_status('timeout', f'{condition_name}_timeout')
142
+ self._conditions[condition_name]['interface'] = self._setup_client(
143
+ condition_name=condition_name,
144
+ service_name=service_name,
145
+ srv_type=srv_type,
146
+ srv_req=srv_req,
147
+ value_check=value_check,
148
+ period=period,
149
+ start_on_status=start_on_status,
150
+ timeout=timeout
151
+ )
152
+
153
+ def register_status(self, status_name, conditions, trigger='ALL'):
154
+ """
155
+ Registers a status to monitor.
156
+
157
+ Parameters:
158
+ status_name (str): The name of the status.
159
+ conditions (list of str): A list of condition names that contribute to this status.
160
+ trigger (str): 'ANY' or 'ALL' - determines how multiple conditions are evaluated.
161
+ """
162
+ # Prevent overwriting existing statuses
163
+ if status_name in self._statuses:
164
+ self.node.get_logger().warning(f"Status '{status_name}' is already registered. Skipping registration.")
165
+ return
166
+ self._statuses[status_name] = {
167
+ 'trigger': trigger,
168
+ 'conditions': conditions
169
+ }
170
+ self._conditions.setdefault(status_name, {'value': None, 'interface': 'status'}) # Ensure the status itself is also a condition for potential nesting
171
+
172
+
173
+ def add_condition_to_status(self, status_name, condition_name):
174
+ """
175
+ Dynamically adds a condition to an existing status.
176
+
177
+ Parameters:
178
+ status_name (str): The name of the status to update.
179
+ condition_name (str): The name of the condition to add.
180
+ """
181
+ if status_name in self._statuses:
182
+ self._statuses[status_name]['conditions'].append(condition_name)
183
+ else:
184
+ self.node.get_logger().warning(f"Status '{status_name}' not found. Cannot add condition '{condition_name}'.")
185
+
186
+ def get_status(self, status_name):
187
+ """
188
+ Checks the status by dynamically resolving its underlying condition values.
189
+
190
+ Returns:
191
+ bool: True if the logical trigger condition is met, False otherwise.
192
+ """
193
+ status = self._statuses.get(status_name)
194
+ if not status:
195
+ self.node.get_logger().warning(f"Status '{status_name}' is not registered.")
196
+ return False
197
+
198
+ if len(status['conditions']) == 0:
199
+ return False # A status with no conditions is considered always False
200
+
201
+ # If a condition is actually another status, we need to evaluate it recursively by calling get_status on it.
202
+ # TODO: This could be optimized with memoization if we expect deep nesting, but for now we assume simple cases.
203
+ for cond in status['conditions']:
204
+ if self._conditions[cond]['interface'] == 'status':
205
+ # If the condition is actually another status, we need to evaluate it recursively
206
+ self._conditions[cond]['value'] = self.get_status(cond)
207
+
208
+ # Map the list of condition strings to their current real-time boolean states
209
+ # Using .get('value', False) handles uninitialized or pending states safely.
210
+ resolved_values = [
211
+ self._conditions.get(cond, {}).get('value', False)
212
+ for cond in status['conditions']
213
+ ]
214
+
215
+ # 2. Evaluate the values against the gate logic
216
+ if status['trigger'] == 'ANY':
217
+ return any(resolved_values)
218
+ elif status['trigger'] == 'ALL':
219
+ return all(resolved_values)
220
+
221
+ return False
222
+
223
+ def wait_for_status(self, status_name: str, poll_interval: float = 0.1) -> bool:
224
+ """
225
+ Blocks synchronously until the status becomes True, 'complete' triggers,
226
+ or the configured timeout duration is exceeded.
227
+
228
+ Returns:
229
+ bool: True if the status was successfully reached, False otherwise.
230
+ """
231
+ start_time = time.time()
232
+
233
+ while not self.get_status(status_name):
234
+ # 1. Check if an external mechanism or internal logic set 'complete'
235
+ if self.get_status("complete"):
236
+ break
237
+
238
+ # 2. Check if the time elapsed exceeds our timeout limit
239
+ if (time.time() - start_time) > self._timeout:
240
+ self._statuses["timeout"] = True
241
+ return False
242
+
243
+ # 3. Yield thread execution to allow rclpy executor thread
244
+ # or topic callbacks to flush and update data structures
245
+ time.sleep(poll_interval)
246
+
247
+ # Double check we didn't exit the loop due to a break condition failure
248
+ return self.get_status(status_name)
249
+
250
+ def _update_condition(self, condition_name, value):
251
+ """
252
+ Updates the value of a condition and checks if the associated status is triggered.
253
+ """
254
+ self._conditions[condition_name]['value'] = value
255
+ self._logger.info(f"Condition '{condition_name}' updated: {value}.")
256
+
257
+ def _setup_subscription(self, condition_name, topic_name, msg_type, value_check=None):
258
+ """
259
+ Sets up a subscription to a topic and returns a function that checks for the desired condition.
260
+ """
261
+ sub = self.node.create_subscription(
262
+ msg_type,
263
+ topic_name,
264
+ lambda msg: self._update_condition(condition_name, value_check(msg) if value_check else True),
265
+ 10
266
+ )
267
+
268
+ return sub
269
+
270
+ def _setup_client(self,
271
+ condition_name,
272
+ service_name,
273
+ srv_type,
274
+ srv_req,
275
+ value_check=None,
276
+ period: float = 1.0,
277
+ start_on_status: str = None,
278
+ timeout: float = None
279
+ ):
280
+ """
281
+ Sets up a service client that periodically calls the service and updates the condition based on the response.
282
+ """
283
+ client = self.node.create_client(
284
+ srv_type,
285
+ service_name
286
+ )
287
+
288
+ def service_poll_worker():
289
+ """Background thread that periodically calls the service and updates the condition status."""
290
+
291
+ worker_start_time = None
292
+
293
+ def check_timeout_breach():
294
+ if timeout is not None and (time.time() - worker_start_time) > timeout:
295
+ self.node.get_logger().error(f"[{condition_name}] Master timeout of {timeout}s exceeded.")
296
+ # Explicitly fail the condition so the test state knows it timed out
297
+ self._update_condition(condition_name, False)
298
+ self._update_condition(f'{condition_name}_timeout', True) # Set the timeout flag
299
+ return True
300
+ return False
301
+
302
+ if start_on_status:
303
+ # Wait until the specified status is triggered before starting the service calls
304
+ while rclpy.ok() and not self._stop_event.is_set():
305
+ if self.get_status(start_on_status):
306
+ worker_start_time = time.time()
307
+ break
308
+ time.sleep(0.1)
309
+ else:
310
+ worker_start_time = time.time()
311
+
312
+ # Wait for the service to become available
313
+ while rclpy.ok() and not self._stop_event.is_set():
314
+ if check_timeout_breach():
315
+ return
316
+ if client.wait_for_service(timeout_sec=1.0):
317
+ break
318
+
319
+ # Periodically call the service and check the response
320
+ while rclpy.ok() and not self._stop_event.is_set():
321
+ if check_timeout_breach():
322
+ return
323
+ start_time = time.time()
324
+ # Before making the service call, check if the condition has already been met to avoid unnecessary calls
325
+ if self._conditions[condition_name]['value']:
326
+ break
327
+
328
+ req = srv_req() if callable(srv_req) else srv_req
329
+
330
+ future = client.call_async(req)
331
+
332
+ timeout_limit = 5.0
333
+ while rclpy.ok() and not self._stop_event.is_set() and not future.done():
334
+ if time.time() - start_time > timeout_limit:
335
+ break
336
+ time.sleep(0.01) # Cooperatively yield execution slice
337
+
338
+ if future.done():
339
+ try:
340
+ response = future.result()
341
+ if value_check is None or value_check(response):
342
+ self._update_condition(condition_name, True)
343
+ break
344
+ except Exception as e:
345
+ self.node.get_logger().error(f"Service call failed: {e}")
346
+ else:
347
+ self.node.get_logger().error("Service call timed out")
348
+
349
+ elapsed = time.time() - start_time
350
+ time_to_sleep = max(0, period - elapsed)
351
+ time.sleep(time_to_sleep)
352
+
353
+ poll_thread = threading.Thread(target=service_poll_worker, daemon=True)
354
+ poll_thread.start()
355
+
356
+
357
+ def _run(self):
358
+ """Internal background loop running the executor."""
359
+ self._is_running.set()
360
+
361
+ while rclpy.ok() and not self._stop_event.is_set():
362
+ # Safely process pending callbacks on the internal node
363
+ self._executor.spin_once(timeout_sec=0.0)
364
+
365
+ # Check for episode timeout
366
+ if self._timeout is not None and (time.time() - self._start_time) > self._timeout:
367
+ self.node.get_logger().error(f"Episode timeout of {self._timeout}s exceeded.")
368
+ self._update_condition('episode_timeout', True)
369
+
370
+ break
371
+
372
+
373
+ def shutdown(self):
374
+ """
375
+ Shuts down the internal ROS node and cleans up resources.
376
+ """
377
+ self._stop_event.set()
378
+
379
+ if hasattr(self, '_executor'):
380
+ self._executor.shutdown()
381
+
382
+ if hasattr(self, '_spin_thread') and self._spin_thread.is_alive():
383
+ self._spin_thread.join(timeout=1.0)
384
+
385
+ # Clean up the node itself
386
+ if hasattr(self, 'node'):
387
+ self.node.destroy_node()
@@ -0,0 +1,32 @@
1
+ import pytest
2
+
3
+ from pytest_ros import RCLPY_AVAILABLE
4
+ if RCLPY_AVAILABLE:
5
+ import rclpy
6
+
7
+ from .process import ROSProcessManager
8
+ from .monitor import EpisodeMonitor
9
+
10
+
11
+
12
+ @pytest.fixture(scope="session", autouse=True)
13
+ def ros_context():
14
+ """Session-scoped fixture to manage ROS context initialization and shutdown."""
15
+ rclpy.init()
16
+ yield
17
+ if rclpy.ok():
18
+ rclpy.shutdown()
19
+
20
+ @pytest.fixture
21
+ def ros_process_manager():
22
+ """Factory fixture to manage ROS launch processes."""
23
+ manager = ROSProcessManager()
24
+ yield manager
25
+ manager.terminate_all() # Ensure all ROS processes are terminated at the end of the session
26
+
27
+ @pytest.fixture
28
+ def ros_monitor():
29
+ """Fixture to provide an EpisodeMonitor instance for monitoring test conditions."""
30
+ monitor = EpisodeMonitor(name="episode_monitor")
31
+ yield monitor
32
+ monitor.shutdown()
@@ -0,0 +1,108 @@
1
+ import signal
2
+ import subprocess
3
+ import uuid
4
+ import os
5
+ import psutil
6
+
7
+
8
+ class MultiProcessManager:
9
+ """
10
+ A utility class to manage multiple processes in a pytest session,
11
+ ensuring that all processes are properly terminated at the end of the session.
12
+ """
13
+ def __init__(self, tag="PYTEST_MULTIPROC_ID"):
14
+ self.tag = tag
15
+ self.session_id = str(uuid.uuid4())
16
+ self.processes = []
17
+
18
+ def runner(self, cmd):
19
+ # Inject the tag into the environment
20
+ env = os.environ.copy()
21
+ env[self.tag] = self.session_id
22
+
23
+ # Start the process group
24
+ proc = subprocess.Popen(
25
+ cmd,
26
+ env=env,
27
+ preexec_fn=os.setsid,
28
+ shell=True
29
+ )
30
+
31
+ self.processes.append(proc)
32
+
33
+ return proc
34
+
35
+
36
+ def stop_process(self, proc):
37
+ """Kill a process group."""
38
+ try:
39
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
40
+ except ProcessLookupError:
41
+ pass
42
+
43
+ if proc in self.processes:
44
+ self.processes.remove(proc)
45
+
46
+ def terminate_all(self):
47
+ """Kills everything tagged with this session ID."""
48
+ for proc in list(self.processes):
49
+ try:
50
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
51
+ except ProcessLookupError:
52
+ pass
53
+
54
+ for proc in psutil.process_iter(['environ']):
55
+ try:
56
+ if (env := proc.info.get('environ')) and env.get(self.tag) == self.session_id:
57
+ proc.send_signal(signal.SIGKILL)
58
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
59
+ continue
60
+
61
+
62
+ class ROSProcessManager(MultiProcessManager):
63
+ """
64
+ A specialized context manager for managing ROS processes, ensuring that all ROS nodes
65
+ launched during the pytest session are properly terminated at the end of the session.
66
+
67
+ Manages ROS 2 launch files.
68
+
69
+ Example:
70
+ with orch.launch(package_name='my_package', launch_file='my_launch.py', arguments=['arg1:=value1']):
71
+ # Run tests that depend on the launched ROS nodes
72
+ """
73
+ def __init__(self, ):
74
+ super().__init__(tag="ROS_SESSION_ID")
75
+
76
+ def launch(self, package_name, launch_file, arguments=None):
77
+ """
78
+ Launches a ROS 2 launch file.
79
+
80
+ Returns a context manager that can be used to manage the lifecycle of the launched processes.
81
+
82
+ Example: orch.launch(arguments=['arg1:=value1', 'arg2:=value2'])
83
+ """
84
+ cmd = ["ros2", "launch", package_name, launch_file]
85
+ if arguments:
86
+ cmd.extend(arguments)
87
+
88
+ cmd = ' '.join(cmd) # Join the command list into a single string for better logging
89
+
90
+ print(f"Launching ROS with command: {cmd}") # Debug statement to verify the command being launched
91
+
92
+ # This uses the 'runner' context manager from the base class
93
+ return self.runner(cmd)
94
+
95
+
96
+ def run(self, package_name, run_process, arguments=None):
97
+ """
98
+ Returns a context manager that can be used to manage the lifecycle of the run process.
99
+
100
+ Example: orch.run(package_name='my_package', run_process='my_run.py', arguments=['arg1:=value1', 'arg2:=value2'])
101
+ """
102
+ cmd = ["ros2", "run", package_name, run_process]
103
+ if arguments:
104
+ cmd.extend(arguments)
105
+
106
+ # This uses the 'runner' context manager from the base class
107
+ return self.runner(cmd)
108
+
@@ -0,0 +1,108 @@
1
+ import cv2
2
+ import logging
3
+ import numpy as np
4
+ import threading
5
+
6
+ from pytest_ros import RCLPY_AVAILABLE
7
+ if RCLPY_AVAILABLE:
8
+ import rclpy
9
+ from rclpy.node import Node
10
+ from sensor_msgs.msg import Image
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ class VideoRecorder:
15
+ """
16
+ This utility enables direct recording of ROS image topics to MP4 files for assessment purposes.
17
+ It is not intended for logging but rather for quick visual verification of test runs.
18
+
19
+ Subscribes to a ROS image topic and writes frames to an MP4 file.
20
+
21
+ Usage::
22
+ recorder = VideoRecorder(node, CAMERA_TOPIC, "/tmp/sim_logs/run.mp4")
23
+ recorder.start()
24
+ # ... run test ...
25
+ recorder.stop() # flushes and closes the file
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ node,
31
+ topic: str,
32
+ output_path: str,
33
+ fps: int = 12
34
+ ):
35
+ if type(node) is str:
36
+ rclpy.init()
37
+ node = rclpy.create_node(node)
38
+ self._node = node
39
+ self._output_path = output_path
40
+ self._fps = fps
41
+ self._writer: cv2.VideoWriter | None = None
42
+ self._lock = threading.Lock()
43
+ self._sub = None
44
+ self._topic = topic
45
+
46
+ def start(self) -> None:
47
+ self._sub = self._node.create_subscription(
48
+ Image,
49
+ self._topic,
50
+ self._on_image,
51
+ 10,
52
+ )
53
+ logger.info(f"[VIDEO] Recording {self._topic} → {self._output_path}")
54
+
55
+ def stop(self) -> None:
56
+ if self._sub is not None:
57
+ self._node.destroy_subscription(self._sub)
58
+ self._sub = None
59
+ with self._lock:
60
+ if self._writer is not None:
61
+ self._writer.release()
62
+ self._writer = None
63
+ logger.info(f"[VIDEO] Saved {self._output_path}")
64
+
65
+ def _on_image(self, msg: Image) -> None:
66
+ """Convert a sensor_msgs/Image to BGR and append to the video file."""
67
+ try:
68
+ frame = self._decode(msg)
69
+ except Exception as exc:
70
+ logger.warning(f"[VIDEO] Frame decode error: {exc}")
71
+ return
72
+
73
+ with self._lock:
74
+ if self._writer is None:
75
+ h, w = frame.shape[:2]
76
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v")
77
+ self._writer = cv2.VideoWriter(
78
+ self._output_path, fourcc, self._fps, (w, h)
79
+ )
80
+ self._writer.write(frame)
81
+
82
+ @staticmethod
83
+ def _decode(msg: Image) -> np.ndarray:
84
+ """Return a BGR uint8 ndarray from a sensor_msgs/Image."""
85
+ enc = msg.encoding.lower()
86
+ data = np.frombuffer(msg.data, dtype=np.uint8)
87
+
88
+ if enc in ("rgb8", "bgr8", "mono8"):
89
+ channels = 1 if enc == "mono8" else 3
90
+ frame = data.reshape((msg.height, msg.width, channels))
91
+ if enc == "rgb8":
92
+ frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)
93
+ elif enc == "mono8":
94
+ frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
95
+ elif enc == "rgba8":
96
+ frame = data.reshape((msg.height, msg.width, 4))
97
+ frame = cv2.cvtColor(frame, cv2.COLOR_RGBA2BGR)
98
+ elif enc in ("16uc1", "mono16"):
99
+ data16 = np.frombuffer(msg.data, dtype=np.uint16).reshape(
100
+ (msg.height, msg.width)
101
+ )
102
+ frame = cv2.cvtColor(
103
+ (data16 >> 8).astype(np.uint8), cv2.COLOR_GRAY2BGR
104
+ )
105
+ else:
106
+ raise ValueError(f"Unsupported encoding: {msg.encoding}")
107
+
108
+ return frame