Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions python/foxglove-sdk/python/foxglove/_foxglove_py/mcap.pyi
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from enum import Enum
from typing import Any
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from ..layouts import Layout

class MCAPCompression(Enum):
"""
Expand Down Expand Up @@ -116,10 +119,26 @@ class MCAPWriter:
Common uses include storing configuration files, calibration data, or other
reference material related to the recording.

:param log_time: Time at which the attachment was logged, in nanoseconds since epoch.
:param create_time: Time at which the attachment data was created, in nanoseconds since epoch.
:param log_time: Time at which the attachment was logged, in nanoseconds since
epoch.
:param create_time: Time at which the attachment data was created, in nanoseconds
since epoch.
:param name: Name of the attachment (e.g., "config.json").
:param media_type: MIME type of the attachment (e.g., "application/json").
:param data: Binary content of the attachment.
"""
...

def write_layout(self, layout: "Layout", layout_name: str) -> None:
"""
Write a layout to the MCAP file.

The layout is serialized to JSON and accumulated internally. All layouts
will be written as a single metadata record under the "foxglove.layout"
key when the writer is closed. This allows multiple layouts to be stored
in the same metadata record with different names.

:param layout: A Layout object from foxglove.layouts.
:param layout_name: The name to use as the key in the metadata record.
"""
...
59 changes: 59 additions & 0 deletions python/foxglove-sdk/python/foxglove/tests/test_mcap.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import pytest
from foxglove import Channel, ChannelDescriptor, Context, open_mcap
from foxglove.layouts import Layout, MarkdownPanel
from foxglove.mcap import MCAPWriteOptions

chan = Channel("test", schema={"type": "object"})
Expand Down Expand Up @@ -223,6 +224,64 @@ def test_write_metadata(tmp_mcap: Path) -> None:
_verify_metadata_in_file(tmp_mcap, expected_metadata)


def test_write_layout(tmp_mcap: Path) -> None:
"""Test writing multiple layouts to MCAP file."""
import json

# Create layouts with different panels
layout1 = Layout(
content=MarkdownPanel(
title="Test Panel 1",
)
)
layout2 = Layout(
content=MarkdownPanel(
title="Test Panel 2",
)
)

layout_name1 = "my_first_layout"
layout_name2 = "my_second_layout"

with open_mcap(tmp_mcap) as writer:
writer.write_layout(layout1, layout_name1)
writer.write_layout(layout2, layout_name2)

# Log some messages
for ii in range(5):
chan.log({"foo": ii})

# Verify layouts were written correctly as metadata
import mcap.reader

with open(tmp_mcap, "rb") as f:
reader = mcap.reader.make_reader(f)

found_layout = False
for record in reader.iter_metadata():
if record.name == "foxglove.layout":
found_layout = True
# Both layouts should be in the same metadata record
assert layout_name1 in record.metadata
assert layout_name2 in record.metadata

# Verify the first layout
layout_json1 = record.metadata[layout_name1]
layout_data1 = json.loads(layout_json1)
assert layout_data1.get("version") == 1
assert "content" in layout_data1
assert layout_data1["content"]["panelType"] == "Markdown"

# Verify the second layout
layout_json2 = record.metadata[layout_name2]
layout_data2 = json.loads(layout_json2)
assert layout_data2.get("version") == 1
assert "content" in layout_data2
assert layout_data2["content"]["panelType"] == "Markdown"

assert found_layout, "Layout metadata not found in MCAP file"


def test_channel_filter(make_tmp_mcap: Callable[[], Path]) -> None:
tmp_1 = make_tmp_mcap()
tmp_2 = make_tmp_mcap()
Expand Down
2 changes: 1 addition & 1 deletion python/foxglove-sdk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ fn open_mcap(
};

let handle = handle.create(writer).map_err(PyFoxgloveError::from)?;
Ok(PyMcapWriter(Some(handle)))
Ok(PyMcapWriter::new(handle))
}

#[pyfunction]
Expand Down
54 changes: 50 additions & 4 deletions python/foxglove-sdk/src/mcap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,19 @@ impl From<PyMcapWriteOptions> for McapWriteOptions {
/// If the writer is not closed by the time it is garbage collected, it will be
/// closed automatically, and any errors will be logged.
#[pyclass(name = "MCAPWriter", module = "foxglove.mcap")]
pub(crate) struct PyMcapWriter(pub(crate) Option<McapWriterHandle<BufWriter<WriterInner>>>);
pub(crate) struct PyMcapWriter {
pub(crate) writer: Option<McapWriterHandle<BufWriter<WriterInner>>>,
layouts: std::collections::BTreeMap<String, String>,
}

impl PyMcapWriter {
pub(crate) fn new(writer: McapWriterHandle<BufWriter<WriterInner>>) -> Self {
Self {
writer: Some(writer),
layouts: std::collections::BTreeMap::new(),
}
}
}

impl Drop for PyMcapWriter {
fn drop(&mut self) {
Expand Down Expand Up @@ -239,7 +251,14 @@ impl PyMcapWriter {
/// You may call this to explicitly close the writer. Note that the writer will be automatically
/// closed for you when it is garbage collected, or when exiting the context manager.
fn close(&mut self) -> PyResult<()> {
if let Some(writer) = self.0.take() {
if let Some(writer) = self.writer.take() {
// Write all accumulated layouts as a single metadata record
if !self.layouts.is_empty() {
let layouts = std::mem::take(&mut self.layouts);
writer
.write_metadata("foxglove.layout", layouts)
.map_err(PyFoxgloveError::from)?;
}
writer.close().map_err(PyFoxgloveError::from)?;
}
Ok(())
Expand All @@ -254,7 +273,7 @@ impl PyMcapWriter {
name: &str,
metadata: std::collections::BTreeMap<String, String>,
) -> PyResult<()> {
if let Some(writer) = &self.0 {
if let Some(writer) = &self.writer {
writer
.write_metadata(name, metadata)
.map_err(PyFoxgloveError::from)?;
Expand Down Expand Up @@ -284,7 +303,7 @@ impl PyMcapWriter {
media_type: String,
data: Vec<u8>,
) -> PyResult<()> {
if let Some(writer) = &self.0 {
if let Some(writer) = &self.writer {
writer
.attach(&foxglove::McapAttachment {
log_time,
Expand All @@ -299,6 +318,33 @@ impl PyMcapWriter {
}
Ok(())
}

/// Write a layout to the MCAP file.
///
/// The layout is serialized to JSON and accumulated internally. All layouts
/// will be written as a single metadata record under the "foxglove.layout"
/// key when the writer is closed.
///
/// :param layout: A Layout object from foxglove.layouts.
/// :param layout_name: The name to use as the key in the metadata record.
fn write_layout(
&mut self,
py: Python<'_>,
layout: Py<PyAny>,
layout_name: String,
) -> PyResult<()> {
if self.writer.is_none() {
return Err(PyFoxgloveError::from(foxglove::FoxgloveError::SinkClosed).into());
}

// Call to_json() on the layout object
let json_str: String = layout.call_method0(py, "to_json")?.extract(py)?;

// Add to the internal layouts map (will be written on close)
self.layouts.insert(layout_name, json_str);

Ok(())
}
}

pub fn register_submodule(parent_module: &Bound<'_, PyModule>) -> PyResult<()> {
Expand Down