import importlib.util import json import os import pytest import ray from ray.data.datasource.path_util import _unwrap_protocol from ray.data.tests.conftest import * # noqa from ray.tests.conftest import * # noqa # Skip all tests if mcap is not available MCAP_AVAILABLE = importlib.util.find_spec("mcap") is not None pytestmark = pytest.mark.skipif( not MCAP_AVAILABLE, reason="mcap module not available. Install with: pip install mcap", ) def create_test_mcap_file(file_path: str, messages: list) -> None: """Create a test MCAP file with given messages.""" from mcap.writer import Writer with open(file_path, "wb") as stream: writer = Writer(stream) writer.start(profile="", library="ray-test") # Register schema schema_id = writer.register_schema( name="test_schema", encoding="jsonschema", data=json.dumps( { "type": "object", "properties": { "value": {"type": "number"}, "name": {"type": "string"}, }, } ).encode(), ) # Register channels and write messages channels = {} for msg in messages: topic = msg["topic"] if topic not in channels: channels[topic] = writer.register_channel( schema_id=schema_id, topic=topic, message_encoding="json", ) writer.add_message( channel_id=channels[topic], log_time=msg["log_time"], publish_time=msg.get("publish_time", msg["log_time"]), data=json.dumps(msg["data"]).encode(), ) writer.finish() @pytest.fixture def simple_mcap_file(tmp_path): """Fixture providing a simple MCAP file with one message.""" path = os.path.join(tmp_path, "test.mcap") messages = [ { "topic": "/test", "data": {"value": 1}, "log_time": 1000000000, } ] create_test_mcap_file(path, messages) return path @pytest.fixture def basic_mcap_file(tmp_path): """Fixture providing a basic MCAP file with two different topics.""" path = os.path.join(tmp_path, "test.mcap") messages = [ { "topic": "/camera/image", "data": {"frame_id": 1, "timestamp": 1000}, "log_time": 1000000000, }, { "topic": "/lidar/points", "data": {"point_count": 1024, "timestamp": 2000}, "log_time": 2000000000, }, ] create_test_mcap_file(path, messages) return path @pytest.fixture def multi_topic_mcap_file(tmp_path): """Fixture providing an MCAP file with 9 messages across 3 topics.""" path = os.path.join(tmp_path, "multi_topic.mcap") base_time = 1000000000 messages = [] for i in range(9): topics = ["/topic_a", "/topic_b", "/topic_c"] topic = topics[i % 3] messages.append( { "topic": topic, "data": {"seq": i, "topic": topic}, "log_time": base_time + i * 1000000, } ) create_test_mcap_file(path, messages) return path @pytest.fixture def time_series_mcap_file(tmp_path): """Fixture providing an MCAP file with 10 time-sequenced messages.""" path = os.path.join(tmp_path, "time_test.mcap") base_time = 1000000000 messages = [ { "topic": "/test_topic", "data": {"seq": i}, "log_time": base_time + i * 1000000, } for i in range(10) ] create_test_mcap_file(path, messages) return path, base_time def test_read_mcap_basic(ray_start_regular_shared, basic_mcap_file): """Test basic MCAP file reading.""" ds = ray.data.read_mcap(basic_mcap_file) # Test metadata operations assert ds.count() == 2 assert ds.input_files() == [_unwrap_protocol(basic_mcap_file)] # Verify basic fields are present rows = ds.take_all() for row in rows: assert "data" in row assert "topic" in row assert "log_time" in row assert "publish_time" in row def test_read_mcap_multiple_files(ray_start_regular_shared, tmp_path): """Test reading multiple MCAP files.""" paths = [] for i in range(2): path = os.path.join(tmp_path, f"test_{i}.mcap") messages = [ { "topic": f"/test_{i}", "data": {"file_id": i}, "log_time": 1000000000 + i * 1000000, } ] create_test_mcap_file(path, messages) paths.append(path) ds = ray.data.read_mcap(paths) assert ds.count() == 2 assert set(ds.input_files()) == {_unwrap_protocol(p) for p in paths} rows = ds.take_all() file_ids = {row["data"]["file_id"] for row in rows} assert file_ids == {0, 1} def test_read_mcap_directory(ray_start_regular_shared, tmp_path): """Test reading MCAP files from a directory.""" # Create MCAP files in directory for i in range(2): path = os.path.join(tmp_path, f"data_{i}.mcap") messages = [ { "topic": f"/dir_test_{i}", "data": {"index": i}, "log_time": 1000000000 + i * 1000000, } ] create_test_mcap_file(path, messages) ds = ray.data.read_mcap(tmp_path) assert ds.count() == 2 def test_read_mcap_topic_filtering(ray_start_regular_shared, multi_topic_mcap_file): """Test filtering by topics.""" # Test topic filtering topics = {"/topic_a", "/topic_b"} ds = ray.data.read_mcap(multi_topic_mcap_file, topics=topics) rows = ds.take_all() actual_topics = {row["topic"] for row in rows} assert actual_topics.issubset(topics) assert len(rows) == 6 # 2/3 of messages def test_read_mcap_time_range_filtering( ray_start_regular_shared, time_series_mcap_file ): """Test filtering by time range.""" path, base_time = time_series_mcap_file # Filter to first 5 messages time_range = (base_time, base_time + 5000000) ds = ray.data.read_mcap(path, time_range=time_range) rows = ds.take_all() assert len(rows) <= 5 for row in rows: assert base_time <= row["log_time"] <= base_time + 5000000 def test_read_mcap_message_type_filtering(ray_start_regular_shared, simple_mcap_file): """Test filtering by message types.""" # Filter with existing schema ds = ray.data.read_mcap(simple_mcap_file, message_types={"test_schema"}) assert ds.count() == 1 # Filter with non-existent schema ds = ray.data.read_mcap(simple_mcap_file, message_types={"nonexistent"}) assert ds.count() == 0 @pytest.mark.parametrize("include_metadata", [True, False]) def test_read_mcap_include_metadata( ray_start_regular_shared, simple_mcap_file, include_metadata ): """Test include_metadata option.""" ds = ray.data.read_mcap(simple_mcap_file, include_metadata=include_metadata) rows = ds.take_all() if include_metadata: assert "schema_name" in rows[0] assert "channel_id" in rows[0] else: assert "schema_name" not in rows[0] assert "channel_id" not in rows[0] def test_read_mcap_include_paths(ray_start_regular_shared, simple_mcap_file): """Test include_paths option.""" ds = ray.data.read_mcap(simple_mcap_file, include_paths=True) rows = ds.take_all() for row in rows: assert "path" in row assert simple_mcap_file in row["path"] def test_read_mcap_invalid_time_range(ray_start_regular_shared, simple_mcap_file): """Test validation of time range parameters.""" # Start time >= end time with pytest.raises(ValueError, match="start_time must be less than end_time"): ray.data.read_mcap(simple_mcap_file, time_range=(2000, 1000)) # Negative times with pytest.raises(ValueError, match="time values must be non-negative"): ray.data.read_mcap(simple_mcap_file, time_range=(-1000, 2000)) def test_read_mcap_missing_dependency(ray_start_regular_shared, simple_mcap_file): """Test graceful failure when mcap library is missing.""" from unittest.mock import patch with patch.dict("sys.modules", {"mcap": None}): with pytest.raises(ImportError, match="MCAPDatasource.*depends on 'mcap'"): ray.data.read_mcap(simple_mcap_file) def test_read_mcap_nonexistent_file(ray_start_regular_shared): """Test handling of nonexistent files.""" with pytest.raises(Exception): # FileNotFoundError or similar ds = ray.data.read_mcap("/nonexistent/file.mcap") ds.materialize() # Force execution @pytest.mark.parametrize("override_num_blocks", [1, 2]) def test_read_mcap_override_num_blocks( ray_start_regular_shared, tmp_path, override_num_blocks ): """Test override_num_blocks parameter.""" path = os.path.join(tmp_path, "blocks_test.mcap") messages = [ { "topic": "/test", "data": {"seq": i}, "log_time": 1000000000 + i * 1000000, } for i in range(3) ] create_test_mcap_file(path, messages) ds = ray.data.read_mcap(path, override_num_blocks=override_num_blocks) # Should still read all the data assert ds.count() == 3 rows = ds.take_all() assert len(rows) == 3 def test_read_mcap_file_extensions(ray_start_regular_shared, tmp_path): """Test file extension filtering.""" # Create MCAP file mcap_path = os.path.join(tmp_path, "data.mcap") messages = [ { "topic": "/test", "data": {"test": "mcap_data"}, "log_time": 1000000000, } ] create_test_mcap_file(mcap_path, messages) # Create non-MCAP file other_path = os.path.join(tmp_path, "data.txt") with open(other_path, "w") as f: f.write("not mcap data") # Should only read .mcap files by default ds = ray.data.read_mcap(tmp_path) assert ds.count() == 1 rows = ds.take_all() assert rows[0]["data"]["test"] == "mcap_data" @pytest.mark.parametrize("ignore_missing_paths", [True, False]) def test_read_mcap_ignore_missing_paths( ray_start_regular_shared, simple_mcap_file, ignore_missing_paths ): """Test ignore_missing_paths parameter.""" paths = [simple_mcap_file, "/nonexistent/missing.mcap"] if ignore_missing_paths: ds = ray.data.read_mcap(paths, ignore_missing_paths=ignore_missing_paths) assert ds.count() == 1 assert ds.input_files() == [_unwrap_protocol(simple_mcap_file)] else: with pytest.raises(Exception): # FileNotFoundError or similar ds = ray.data.read_mcap(paths, ignore_missing_paths=ignore_missing_paths) ds.materialize() def test_read_mcap_json_decoding(ray_start_regular_shared, tmp_path): """Test that JSON-encoded messages are properly decoded.""" path = os.path.join(tmp_path, "json_test.mcap") # Test data with nested JSON structure test_data = { "sensor_data": { "temperature": 23.5, "humidity": 45.0, "readings": [1, 2, 3, 4, 5], }, "metadata": {"device_id": "sensor_001", "location": "room_a"}, } messages = [ { "topic": "/sensor/data", "data": test_data, "log_time": 1000000000, } ] create_test_mcap_file(path, messages) assert os.path.exists(path), f"Test MCAP file was not created at {path}" ds = ray.data.read_mcap(path) rows = ds.take_all() assert len(rows) == 1, f"Expected 1 row, got {len(rows)}" row = rows[0] # Verify the data field is properly decoded as a Python dict, not bytes assert isinstance(row["data"], dict), f"Expected dict, got {type(row['data'])}" assert row["data"]["sensor_data"]["temperature"] == 23.5 assert row["data"]["metadata"]["device_id"] == "sensor_001" assert row["data"]["sensor_data"]["readings"] == [1, 2, 3, 4, 5] if __name__ == "__main__": import sys sys.exit(pytest.main(["-v", __file__]))