pytest-ros 0.1.0__py3-none-any.whl

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.
pytest_ros/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ try:
2
+ import rclpy
3
+ RCLPY_AVAILABLE = True
4
+ except ImportError:
5
+ RCLPY_AVAILABLE = False
pytest_ros/_version.py ADDED
@@ -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
pytest_ros/monitor.py ADDED
@@ -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()
pytest_ros/plugin.py ADDED
@@ -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()
pytest_ros/process.py ADDED
@@ -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
@@ -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,10 @@
1
+ pytest_ros/__init__.py,sha256=IzybApI2tvxo-aykVUemQ4qxAe_fpM1PKmEaIzh5htc,97
2
+ pytest_ros/_version.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
3
+ pytest_ros/monitor.py,sha256=V6_VnqhHNDRWl-9PJ0UjITqN0QAwDKe2NkYud98tem4,16216
4
+ pytest_ros/plugin.py,sha256=o4v1SN0FfqrVTyyNAO8kXzWcVOZ6RkbazOFAPjhPA1M,863
5
+ pytest_ros/process.py,sha256=z-ocj0akaIJoaX4eQKHtohMOpkHJHovcL0x9YwijbG8,3481
6
+ pytest_ros/video_recorder.py,sha256=NdNrFn0cEK1eqoE3DkpqjcD6g8oREOVC3QYPbCxJTdg,3575
7
+ pytest_ros-0.1.0.dist-info/METADATA,sha256=OP1vXaBYsXss2ykDNRwWg62jZQPmMZv46j-bhsyjq_k,1236
8
+ pytest_ros-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ pytest_ros-0.1.0.dist-info/entry_points.txt,sha256=3vtrpJJg46WbmMg1Lk9DSgFj5MsU00gXe_RRIsgLLA4,42
10
+ pytest_ros-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ pytest_ros = pytest_ros.plugin