foxglove-sdk 0.14.1__cp312-cp312-musllinux_1_2_i686.whl → 0.16.6__cp312-cp312-musllinux_1_2_i686.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.
- foxglove/__init__.py +164 -52
- foxglove/_foxglove_py/__init__.pyi +94 -32
- foxglove/_foxglove_py/channels.pyi +396 -330
- foxglove/_foxglove_py/cloud.pyi +9 -0
- foxglove/_foxglove_py/mcap.pyi +53 -12
- foxglove/_foxglove_py/schemas.pyi +311 -318
- foxglove/_foxglove_py/schemas_wkt.pyi +8 -9
- foxglove/_foxglove_py/websocket.pyi +120 -76
- foxglove/_foxglove_py.cpython-312-i386-linux-musl.so +0 -0
- foxglove/channel.py +24 -22
- foxglove/channels/__init__.py +2 -0
- foxglove/cloud.py +61 -0
- foxglove/layouts/__init__.py +17 -0
- foxglove/notebook/__init__.py +0 -0
- foxglove/notebook/foxglove_widget.py +82 -0
- foxglove/notebook/notebook_buffer.py +114 -0
- foxglove/notebook/static/widget.js +1 -0
- foxglove/schemas/__init__.py +3 -0
- foxglove/tests/test_context.py +10 -0
- foxglove/tests/test_logging.py +46 -0
- foxglove/tests/test_mcap.py +363 -2
- foxglove/tests/test_server.py +33 -2
- foxglove/websocket.py +40 -18
- foxglove.libs/libgcc_s-8c2f5de4.so.1 +0 -0
- foxglove_sdk-0.16.6.dist-info/METADATA +53 -0
- foxglove_sdk-0.16.6.dist-info/RECORD +35 -0
- {foxglove_sdk-0.14.1.dist-info → foxglove_sdk-0.16.6.dist-info}/WHEEL +1 -1
- foxglove.libs/libgcc_s-27e5a392.so.1 +0 -0
- foxglove_sdk-0.14.1.dist-info/METADATA +0 -29
- foxglove_sdk-0.14.1.dist-info/RECORD +0 -27
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
from typing import TYPE_CHECKING, Literal
|
|
5
|
+
|
|
6
|
+
import anywidget
|
|
7
|
+
import traitlets
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ..layouts import Layout
|
|
11
|
+
from .notebook_buffer import NotebookBuffer
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FoxgloveWidget(anywidget.AnyWidget):
|
|
15
|
+
"""
|
|
16
|
+
A widget that displays a Foxglove viewer in a notebook.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
_esm = pathlib.Path(__file__).parent / "static" / "widget.js"
|
|
20
|
+
width = traitlets.Union(
|
|
21
|
+
[traitlets.Int(), traitlets.Enum(values=["full"])], default_value="full"
|
|
22
|
+
).tag(sync=True)
|
|
23
|
+
height = traitlets.Int(default_value=500).tag(sync=True)
|
|
24
|
+
src = traitlets.Unicode(default_value=None, allow_none=True).tag(sync=True)
|
|
25
|
+
_layout = traitlets.Unicode(default_value=None, allow_none=True).tag(sync=True)
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
buffer: NotebookBuffer,
|
|
31
|
+
width: int | Literal["full"] | None = None,
|
|
32
|
+
height: int | None = None,
|
|
33
|
+
src: str | None = None,
|
|
34
|
+
layout: Layout | None = None,
|
|
35
|
+
):
|
|
36
|
+
"""
|
|
37
|
+
:param buffer: The NotebookBuffer object that contains the data to display in the widget.
|
|
38
|
+
:param width: The width of the widget. Defaults to "full".
|
|
39
|
+
:param height: The height of the widget in pixels. Defaults to 500.
|
|
40
|
+
:param src: The source URL of the Foxglove viewer. Defaults to
|
|
41
|
+
"https://embed.foxglove.dev/".
|
|
42
|
+
"""
|
|
43
|
+
super().__init__()
|
|
44
|
+
if width is not None:
|
|
45
|
+
self.width = width
|
|
46
|
+
else:
|
|
47
|
+
self.width = "full"
|
|
48
|
+
if height is not None:
|
|
49
|
+
self.height = height
|
|
50
|
+
if src is not None:
|
|
51
|
+
self.src = src
|
|
52
|
+
|
|
53
|
+
if layout is not None:
|
|
54
|
+
self._layout = layout.to_json()
|
|
55
|
+
|
|
56
|
+
# Callback to get the data to display in the widget
|
|
57
|
+
self._buffer = buffer
|
|
58
|
+
# Keep track of when the widget is ready to receive data
|
|
59
|
+
self._ready = False
|
|
60
|
+
# Pending data to be sent when the widget is ready
|
|
61
|
+
self._pending_data: list[bytes] = []
|
|
62
|
+
self.on_msg(self._handle_custom_msg)
|
|
63
|
+
self.refresh()
|
|
64
|
+
|
|
65
|
+
def refresh(self) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Refresh the widget by getting the data from the callback function and sending it
|
|
68
|
+
to the widget.
|
|
69
|
+
"""
|
|
70
|
+
data = self._buffer._get_data()
|
|
71
|
+
if not self._ready:
|
|
72
|
+
self._pending_data = data
|
|
73
|
+
else:
|
|
74
|
+
self.send({"type": "update-data"}, data)
|
|
75
|
+
|
|
76
|
+
def _handle_custom_msg(self, msg: dict, buffers: list[bytes]) -> None:
|
|
77
|
+
if msg["type"] == "ready":
|
|
78
|
+
self._ready = True
|
|
79
|
+
|
|
80
|
+
if len(self._pending_data) > 0:
|
|
81
|
+
self.send({"type": "update-data"}, self._pending_data)
|
|
82
|
+
self._pending_data = []
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import uuid
|
|
5
|
+
from tempfile import TemporaryDirectory
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from mcap.reader import make_reader
|
|
9
|
+
|
|
10
|
+
from .._foxglove_py import Context, open_mcap
|
|
11
|
+
from ..layouts import Layout
|
|
12
|
+
from .foxglove_widget import FoxgloveWidget
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NotebookBuffer:
|
|
16
|
+
"""
|
|
17
|
+
A data buffer to collect and manage messages and visualization in Jupyter notebooks.
|
|
18
|
+
|
|
19
|
+
The NotebookBuffer object will buffer all data logged to the provided context. When you
|
|
20
|
+
are ready to visualize the data, you can call the :meth:`show` method to display an embedded
|
|
21
|
+
Foxglove visualization widget. The widget provides a fully-featured Foxglove interface
|
|
22
|
+
directly within your Jupyter notebook, allowing you to explore multi-modal robotics data
|
|
23
|
+
including 3D scenes, plots, images, and more.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, *, context: Context | None = None):
|
|
27
|
+
"""
|
|
28
|
+
Initialize a new NotebookBuffer for collecting logged messages.
|
|
29
|
+
|
|
30
|
+
:param context: The Context used to log the messages. If no Context is provided, the global
|
|
31
|
+
context will be used. Logged messages will be buffered.
|
|
32
|
+
"""
|
|
33
|
+
# We need to keep the temporary directory alive until the writer is closed
|
|
34
|
+
self._temp_directory = TemporaryDirectory()
|
|
35
|
+
self._context = context
|
|
36
|
+
self._files: list[str] = []
|
|
37
|
+
self._create_writer()
|
|
38
|
+
|
|
39
|
+
def show(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
width: int | Literal["full"] | None = None,
|
|
43
|
+
height: int | None = None,
|
|
44
|
+
src: str | None = None,
|
|
45
|
+
layout: Layout | None = None,
|
|
46
|
+
) -> FoxgloveWidget:
|
|
47
|
+
"""
|
|
48
|
+
Show the Foxglove viewer. Call this method as the last step of a notebook cell
|
|
49
|
+
to display the viewer.
|
|
50
|
+
"""
|
|
51
|
+
widget = FoxgloveWidget(
|
|
52
|
+
buffer=self,
|
|
53
|
+
width=width,
|
|
54
|
+
height=height,
|
|
55
|
+
src=src,
|
|
56
|
+
layout=layout,
|
|
57
|
+
)
|
|
58
|
+
return widget
|
|
59
|
+
|
|
60
|
+
def clear(self) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Clear the buffered data.
|
|
63
|
+
"""
|
|
64
|
+
self._writer.close()
|
|
65
|
+
# Delete the temporary directory and all its contents
|
|
66
|
+
self._temp_directory.cleanup()
|
|
67
|
+
# Reset files list
|
|
68
|
+
self._files = []
|
|
69
|
+
# Create a new temporary directory
|
|
70
|
+
self._temp_directory = TemporaryDirectory()
|
|
71
|
+
self._create_writer()
|
|
72
|
+
|
|
73
|
+
def _get_data(self) -> list[bytes]:
|
|
74
|
+
"""
|
|
75
|
+
Retrieve all collected data.
|
|
76
|
+
"""
|
|
77
|
+
# close the current writer
|
|
78
|
+
self._writer.close()
|
|
79
|
+
|
|
80
|
+
if len(self._files) > 1:
|
|
81
|
+
if is_mcap_empty(self._files[-1]):
|
|
82
|
+
# If the last file is empty, remove the last file since it won't add any new data
|
|
83
|
+
# to the buffer
|
|
84
|
+
os.remove(self._files[-1])
|
|
85
|
+
self._files.pop()
|
|
86
|
+
elif is_mcap_empty(self._files[0]):
|
|
87
|
+
# If the first file is empty, remove the first file since it won't add any new data
|
|
88
|
+
# to the buffer
|
|
89
|
+
os.remove(self._files[0])
|
|
90
|
+
self._files.pop(0)
|
|
91
|
+
|
|
92
|
+
# read the content of the files
|
|
93
|
+
contents: list[bytes] = []
|
|
94
|
+
for file_name in self._files:
|
|
95
|
+
with open(file_name, "rb") as f_read:
|
|
96
|
+
contents.append(f_read.read())
|
|
97
|
+
|
|
98
|
+
self._create_writer()
|
|
99
|
+
|
|
100
|
+
return contents
|
|
101
|
+
|
|
102
|
+
def _create_writer(self) -> None:
|
|
103
|
+
random_id = uuid.uuid4().hex[:8]
|
|
104
|
+
file_name = f"{self._temp_directory.name}/log-{random_id}.mcap"
|
|
105
|
+
self._files.append(file_name)
|
|
106
|
+
self._writer = open_mcap(path=file_name, context=self._context)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def is_mcap_empty(file_name: str) -> bool:
|
|
110
|
+
with open(file_name, "rb") as f_read:
|
|
111
|
+
iter = make_reader(f_read).iter_messages()
|
|
112
|
+
is_empty = next(iter, None) is None
|
|
113
|
+
|
|
114
|
+
return is_empty
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var g=EventTarget,f="https://embed.foxglove.dev/",w="foxglove.default-layout";function y(){let t,e;return{promise:new Promise((a,i)=>{t=a,e=i}),resolve:t,reject:e}}var d=class extends g{#e;#a;#d;#r=!1;#n;#t={dataSource:void 0,selectLayout:void 0,extension:[]};#s;#i=!1;constructor(e){super();let{parent:s,src:a,orgSlug:i,initialDataSource:o,initialLayout:r,initialLayoutParams:n,initialExtensions:l,colorScheme:h="auto",embeddedViewer:u}=e;this.#d=i,this.#n=y();let c=a??f;try{this.#a=new URL(c)}catch{throw new Error(`[FoxgloveViewer] Invalid server URL: ${c}`)}u!=null&&this.#a.searchParams.set("embeddedViewer",u),window.addEventListener("message",this.#h),o&&this.setDataSource(o),r!=null&&n==null&&this.setLayoutData(r),n!=null&&this.selectLayout(n),l&&this.installExtensions(l),this.#e=document.createElement("iframe"),this.#e.src=this.#a.href,this.#e.title="Foxglove",this.#e.allow="cross-origin-isolated",this.#e.style.width="100%",this.#e.style.height="100%",this.#e.style.border="none",this.setColorScheme(h),s.appendChild(this.#e)}setDataSource(e){this.#l({type:"set-data-source",payload:e})}selectLayout(e){this.#l({type:"select-layout",payload:e})}async getLayout(){return this.#s||(this.#s=y(),await this.#n.promise,this.#o({type:"get-layout"})),await this.#s.promise}setLayoutData(e){this.selectLayout({storageKey:w,opaqueLayout:e,force:!0})}installExtensions(e){this.#l({type:"install-extension",payload:e})}isReady(){return this.#r}destroy(){this.#i=!0,this.#e.remove(),window.removeEventListener("message",this.#h)}isDestroyed(){return this.#i}setColorScheme(e){this.#e.style.colorScheme=e==="auto"?"normal":e}#l(e){switch(e.type){case"install-extension":this.#t.extension.push(e);break;case"set-data-source":this.#t.dataSource=e;break;case"select-layout":this.#t.selectLayout=e;break}this.#r&&this.#o(e)}#o(e){if(this.#i){console.warn("[FoxgloveViewer] Unable to post command. Frame has been destroyed.");return}v(this.#e.contentWindow,"Invariant: iframe should be loaded."),this.#e.contentWindow.postMessage(e,this.#a.origin)}#h=e=>{let s=new URL(e.origin);if(!(e.source!==this.#e.contentWindow||s.origin!==this.#a.origin)){if(this.#i){console.warn("[FoxgloveViewer] Unable to handle message. Frame has been destroyed.");return}switch(e.data.type){case"foxglove-origin-request":this.#o({type:"origin-ack"});break;case"foxglove-handshake-request":this.#r=!0,this.#n.resolve(),this.#o({type:"handshake-ack",payload:{orgSlug:this.#d,initialDataSource:this.#t.dataSource?.payload,initialLayoutParams:this.#t.selectLayout?.payload,initialExtensions:this.#t.extension.flatMap(a=>a.payload)}});break;case"foxglove-handshake-complete":this.dispatchEvent(new Event("ready"));break;case"foxglove-error":this.dispatchEvent(new CustomEvent("error",{detail:e.data.payload}));break;case"foxglove-layout-data":this.#s?(this.#s.resolve(e.data.payload),this.#s=void 0):console.warn("[FoxgloveViewer] Received layout but getLayout was not called.");break;default:console.warn("[FoxgloveViewer] Unhandled message type:",e.data);break}}}};function v(t,e="no additional info provided"){if(!t)throw new Error("Assertion Error: "+e)}var m="foxglove-notebook-default-layout";function p(t){return{storageKey:m,force:!0,layout:t?JSON.parse(t):void 0}}function x({model:t,el:e}){let s=document.createElement("div"),a=t.get("_layout"),i=new d({parent:s,embeddedViewer:"Python",src:t.get("src"),orgSlug:void 0,initialLayoutParams:p(a)});i.addEventListener("ready",()=>{t.send({type:"ready"})}),t.on("msg:custom",(o,r)=>{if(o.type==="update-data"){let n=r.map((l,h)=>new File([l.buffer],`data-${h}.mcap`));i.setDataSource({type:"file",file:n})}}),s.style.width=t.get("width")==="full"?"100%":`${t.get("width")}px`,s.style.height=`${t.get("height")}px`,t.on("change:width",()=>{s.style.width=t.get("width")==="full"?"100%":`${t.get("width")}px`}),t.on("change:height",()=>{s.style.height=`${t.get("height")}px`}),t.on("change:_layout",()=>{let o=t.get("_layout"),r=p(o);i.selectLayout(r)}),e.appendChild(s)}var R={render:x};export{R as default};
|
foxglove/schemas/__init__.py
CHANGED
|
@@ -40,6 +40,7 @@ from foxglove._foxglove_py.schemas import (
|
|
|
40
40
|
PackedElementFieldNumericType,
|
|
41
41
|
Point2,
|
|
42
42
|
Point3,
|
|
43
|
+
Point3InFrame,
|
|
43
44
|
PointCloud,
|
|
44
45
|
PointsAnnotation,
|
|
45
46
|
PointsAnnotationType,
|
|
@@ -88,6 +89,7 @@ FoxgloveSchema = Union[
|
|
|
88
89
|
PackedElementField,
|
|
89
90
|
Point2,
|
|
90
91
|
Point3,
|
|
92
|
+
Point3InFrame,
|
|
91
93
|
PointCloud,
|
|
92
94
|
PointsAnnotation,
|
|
93
95
|
Pose,
|
|
@@ -139,6 +141,7 @@ __all__ = [
|
|
|
139
141
|
"PackedElementFieldNumericType",
|
|
140
142
|
"Point2",
|
|
141
143
|
"Point3",
|
|
144
|
+
"Point3InFrame",
|
|
142
145
|
"PointCloud",
|
|
143
146
|
"PointsAnnotation",
|
|
144
147
|
"PointsAnnotationType",
|
foxglove/tests/test_logging.py
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
2
5
|
|
|
3
6
|
import pytest
|
|
4
7
|
from foxglove import set_log_level
|
|
@@ -14,3 +17,46 @@ def test_set_log_level_accepts_string_or_int() -> None:
|
|
|
14
17
|
def test_set_log_level_clamps_illegal_values() -> None:
|
|
15
18
|
set_log_level(-1)
|
|
16
19
|
set_log_level(2**64)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_logging_config_with_env() -> None:
|
|
23
|
+
# Run a script in a child process so logger can be re-initialized from env.
|
|
24
|
+
test_script = """
|
|
25
|
+
import logging
|
|
26
|
+
import foxglove
|
|
27
|
+
|
|
28
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
29
|
+
|
|
30
|
+
server = foxglove.start_server(port=0)
|
|
31
|
+
server.stop()
|
|
32
|
+
|
|
33
|
+
print("test_init_with_env_complete")
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
# Default: unset
|
|
37
|
+
env = os.environ.copy()
|
|
38
|
+
env["FOXGLOVE_LOG_LEVEL"] = ""
|
|
39
|
+
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
[sys.executable, "-c", test_script],
|
|
42
|
+
env=env,
|
|
43
|
+
capture_output=True,
|
|
44
|
+
text=True,
|
|
45
|
+
timeout=5,
|
|
46
|
+
)
|
|
47
|
+
assert "test_init_with_env_complete" in result.stdout
|
|
48
|
+
assert "Started server" in result.stderr
|
|
49
|
+
|
|
50
|
+
# Quiet the WS server logging
|
|
51
|
+
env = os.environ.copy()
|
|
52
|
+
env["FOXGLOVE_LOG_LEVEL"] = "debug,foxglove::websocket::server=warn"
|
|
53
|
+
|
|
54
|
+
result = subprocess.run(
|
|
55
|
+
[sys.executable, "-c", test_script],
|
|
56
|
+
env=env,
|
|
57
|
+
capture_output=True,
|
|
58
|
+
text=True,
|
|
59
|
+
timeout=5,
|
|
60
|
+
)
|
|
61
|
+
assert "test_init_with_env_complete" in result.stdout
|
|
62
|
+
assert "Started server" not in result.stderr
|
foxglove/tests/test_mcap.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
from io import SEEK_CUR, SEEK_SET, BytesIO
|
|
1
2
|
from pathlib import Path
|
|
2
|
-
from typing import Callable, Generator, Optional
|
|
3
|
+
from typing import Callable, Generator, Optional, Union
|
|
3
4
|
|
|
4
5
|
import pytest
|
|
5
|
-
from foxglove import Channel, Context, open_mcap
|
|
6
|
+
from foxglove import Channel, ChannelDescriptor, Context, open_mcap
|
|
6
7
|
from foxglove.mcap import MCAPWriteOptions
|
|
7
8
|
|
|
8
9
|
chan = Channel("test", schema={"type": "object"})
|
|
@@ -114,3 +115,363 @@ def test_write_to_different_contexts(make_tmp_mcap: Callable[[], Path]) -> None:
|
|
|
114
115
|
contents2 = tmp_2.read_bytes()
|
|
115
116
|
|
|
116
117
|
assert len(contents1) < len(contents2)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _verify_metadata_in_file(file_path: Path, expected_metadata: dict) -> None:
|
|
121
|
+
"""Helper function to verify metadata in MCAP file matches expected."""
|
|
122
|
+
import mcap.reader
|
|
123
|
+
|
|
124
|
+
with open(file_path, "rb") as f:
|
|
125
|
+
reader = mcap.reader.make_reader(f)
|
|
126
|
+
|
|
127
|
+
found_metadata = {}
|
|
128
|
+
metadata_count = 0
|
|
129
|
+
|
|
130
|
+
for record in reader.iter_metadata():
|
|
131
|
+
metadata_count += 1
|
|
132
|
+
found_metadata[record.name] = dict(record.metadata)
|
|
133
|
+
|
|
134
|
+
# Verify count
|
|
135
|
+
assert metadata_count == len(
|
|
136
|
+
expected_metadata
|
|
137
|
+
), f"Expected {len(expected_metadata)} metadata records, found {metadata_count}"
|
|
138
|
+
|
|
139
|
+
# Verify metadata names and content
|
|
140
|
+
assert set(found_metadata.keys()) == set(
|
|
141
|
+
expected_metadata.keys()
|
|
142
|
+
), "Metadata names don't match"
|
|
143
|
+
|
|
144
|
+
for name, expected_kv in expected_metadata.items():
|
|
145
|
+
assert (
|
|
146
|
+
found_metadata[name] == expected_kv
|
|
147
|
+
), f"Metadata '{name}' has wrong key-value pairs"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _verify_attachments_in_file(
|
|
151
|
+
file_path: Path, expected_attachments: list[dict]
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Helper function to verify attachments in MCAP file match expected."""
|
|
154
|
+
import mcap.reader
|
|
155
|
+
|
|
156
|
+
with open(file_path, "rb") as f:
|
|
157
|
+
reader = mcap.reader.make_reader(f)
|
|
158
|
+
|
|
159
|
+
found_attachments = []
|
|
160
|
+
for attachment in reader.iter_attachments():
|
|
161
|
+
found_attachments.append(
|
|
162
|
+
{
|
|
163
|
+
"log_time": attachment.log_time,
|
|
164
|
+
"create_time": attachment.create_time,
|
|
165
|
+
"name": attachment.name,
|
|
166
|
+
"media_type": attachment.media_type,
|
|
167
|
+
"data": attachment.data,
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Verify count
|
|
172
|
+
assert len(found_attachments) == len(
|
|
173
|
+
expected_attachments
|
|
174
|
+
), f"Expected {len(expected_attachments)} attachments, found {len(found_attachments)}"
|
|
175
|
+
|
|
176
|
+
# Verify each attachment matches expected
|
|
177
|
+
for expected in expected_attachments:
|
|
178
|
+
matching = [a for a in found_attachments if a["name"] == expected["name"]]
|
|
179
|
+
assert len(matching) == 1, f"Attachment '{expected['name']}' not found"
|
|
180
|
+
actual = matching[0]
|
|
181
|
+
assert (
|
|
182
|
+
actual["log_time"] == expected["log_time"]
|
|
183
|
+
), f"Attachment '{expected['name']}' has wrong log_time"
|
|
184
|
+
assert (
|
|
185
|
+
actual["create_time"] == expected["create_time"]
|
|
186
|
+
), f"Attachment '{expected['name']}' has wrong create_time"
|
|
187
|
+
assert (
|
|
188
|
+
actual["media_type"] == expected["media_type"]
|
|
189
|
+
), f"Attachment '{expected['name']}' has wrong media_type"
|
|
190
|
+
assert (
|
|
191
|
+
actual["data"] == expected["data"]
|
|
192
|
+
), f"Attachment '{expected['name']}' has wrong data"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_write_metadata(tmp_mcap: Path) -> None:
|
|
196
|
+
"""Test writing metadata to MCAP file."""
|
|
197
|
+
# Define expected metadata
|
|
198
|
+
expected_metadata = {
|
|
199
|
+
"test1": {"key1": "value1", "key2": "value2"},
|
|
200
|
+
"test2": {"a": "1", "b": "2"},
|
|
201
|
+
"test3": {"x": "y", "z": "w"},
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
with open_mcap(tmp_mcap) as writer:
|
|
205
|
+
# This should not raise an error
|
|
206
|
+
writer.write_metadata("empty", {})
|
|
207
|
+
|
|
208
|
+
# Write basic metadata
|
|
209
|
+
writer.write_metadata("test1", expected_metadata["test1"])
|
|
210
|
+
|
|
211
|
+
# Write multiple metadata records
|
|
212
|
+
writer.write_metadata("test2", expected_metadata["test2"])
|
|
213
|
+
writer.write_metadata("test3", expected_metadata["test3"])
|
|
214
|
+
|
|
215
|
+
# Write empty metadata (should be skipped)
|
|
216
|
+
writer.write_metadata("empty_test", {})
|
|
217
|
+
|
|
218
|
+
# Log some messages
|
|
219
|
+
for ii in range(5):
|
|
220
|
+
chan.log({"foo": ii})
|
|
221
|
+
|
|
222
|
+
# Verify metadata was written correctly
|
|
223
|
+
_verify_metadata_in_file(tmp_mcap, expected_metadata)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_channel_filter(make_tmp_mcap: Callable[[], Path]) -> None:
|
|
227
|
+
tmp_1 = make_tmp_mcap()
|
|
228
|
+
tmp_2 = make_tmp_mcap()
|
|
229
|
+
|
|
230
|
+
ch1 = Channel("/1", schema={"type": "object"})
|
|
231
|
+
ch2 = Channel("/2", schema={"type": "object"})
|
|
232
|
+
|
|
233
|
+
def filter(ch: ChannelDescriptor) -> bool:
|
|
234
|
+
return ch.topic.startswith("/1")
|
|
235
|
+
|
|
236
|
+
mcap1 = open_mcap(tmp_1, channel_filter=filter)
|
|
237
|
+
mcap2 = open_mcap(tmp_2, channel_filter=None)
|
|
238
|
+
|
|
239
|
+
ch1.log({})
|
|
240
|
+
ch2.log({})
|
|
241
|
+
|
|
242
|
+
mcap1.close()
|
|
243
|
+
mcap2.close()
|
|
244
|
+
|
|
245
|
+
assert tmp_1.stat().st_size < tmp_2.stat().st_size
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_attach_basic(tmp_mcap: Path) -> None:
|
|
249
|
+
"""Test writing a single attachment to MCAP file."""
|
|
250
|
+
expected_attachments = [
|
|
251
|
+
{
|
|
252
|
+
"log_time": 1000000000,
|
|
253
|
+
"create_time": 2000000000,
|
|
254
|
+
"name": "config.json",
|
|
255
|
+
"media_type": "application/json",
|
|
256
|
+
"data": b'{"setting": true}',
|
|
257
|
+
}
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
with open_mcap(tmp_mcap) as writer:
|
|
261
|
+
writer.attach(
|
|
262
|
+
log_time=1000000000,
|
|
263
|
+
create_time=2000000000,
|
|
264
|
+
name="config.json",
|
|
265
|
+
media_type="application/json",
|
|
266
|
+
data=b'{"setting": true}',
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
_verify_attachments_in_file(tmp_mcap, expected_attachments)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_attach_multiple(tmp_mcap: Path) -> None:
|
|
273
|
+
"""Test writing multiple attachments to MCAP file."""
|
|
274
|
+
expected_attachments = [
|
|
275
|
+
{
|
|
276
|
+
"log_time": 100,
|
|
277
|
+
"create_time": 200,
|
|
278
|
+
"name": "config.json",
|
|
279
|
+
"media_type": "application/json",
|
|
280
|
+
"data": b'{"setting": true}',
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
"log_time": 300,
|
|
284
|
+
"create_time": 400,
|
|
285
|
+
"name": "calibration.yaml",
|
|
286
|
+
"media_type": "text/yaml",
|
|
287
|
+
"data": b"camera:\n fx: 500\n fy: 500",
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
"log_time": 500,
|
|
291
|
+
"create_time": 600,
|
|
292
|
+
"name": "image.png",
|
|
293
|
+
"media_type": "image/png",
|
|
294
|
+
"data": bytes([0x89, 0x50, 0x4E, 0x47]), # PNG magic bytes
|
|
295
|
+
},
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
with open_mcap(tmp_mcap) as writer:
|
|
299
|
+
writer.attach(
|
|
300
|
+
log_time=100,
|
|
301
|
+
create_time=200,
|
|
302
|
+
name="config.json",
|
|
303
|
+
media_type="application/json",
|
|
304
|
+
data=b'{"setting": true}',
|
|
305
|
+
)
|
|
306
|
+
writer.attach(
|
|
307
|
+
log_time=300,
|
|
308
|
+
create_time=400,
|
|
309
|
+
name="calibration.yaml",
|
|
310
|
+
media_type="text/yaml",
|
|
311
|
+
data=b"camera:\n fx: 500\n fy: 500",
|
|
312
|
+
)
|
|
313
|
+
writer.attach(
|
|
314
|
+
log_time=500,
|
|
315
|
+
create_time=600,
|
|
316
|
+
name="image.png",
|
|
317
|
+
media_type="image/png",
|
|
318
|
+
data=bytes([0x89, 0x50, 0x4E, 0x47]), # PNG magic bytes
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
_verify_attachments_in_file(tmp_mcap, expected_attachments)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_attach_with_messages(tmp_mcap: Path) -> None:
|
|
325
|
+
"""Test writing attachments alongside messages."""
|
|
326
|
+
with open_mcap(tmp_mcap) as writer:
|
|
327
|
+
# Write some messages
|
|
328
|
+
for ii in range(5):
|
|
329
|
+
chan.log({"foo": ii})
|
|
330
|
+
|
|
331
|
+
# Write an attachment
|
|
332
|
+
writer.attach(
|
|
333
|
+
log_time=1000,
|
|
334
|
+
create_time=2000,
|
|
335
|
+
name="notes.txt",
|
|
336
|
+
media_type="text/plain",
|
|
337
|
+
data=b"Recording notes",
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
# Write more messages
|
|
341
|
+
for ii in range(5, 10):
|
|
342
|
+
chan.log({"foo": ii})
|
|
343
|
+
|
|
344
|
+
# Verify attachment was written
|
|
345
|
+
expected_attachments = [
|
|
346
|
+
{
|
|
347
|
+
"log_time": 1000,
|
|
348
|
+
"create_time": 2000,
|
|
349
|
+
"name": "notes.txt",
|
|
350
|
+
"media_type": "text/plain",
|
|
351
|
+
"data": b"Recording notes",
|
|
352
|
+
}
|
|
353
|
+
]
|
|
354
|
+
_verify_attachments_in_file(tmp_mcap, expected_attachments)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def test_attach_after_close(tmp_mcap: Path) -> None:
|
|
358
|
+
"""Test that attaching after close raises an error."""
|
|
359
|
+
writer = open_mcap(tmp_mcap)
|
|
360
|
+
writer.close()
|
|
361
|
+
|
|
362
|
+
with pytest.raises(Exception): # FoxgloveError for SinkClosed
|
|
363
|
+
writer.attach(
|
|
364
|
+
log_time=100,
|
|
365
|
+
create_time=200,
|
|
366
|
+
name="test.txt",
|
|
367
|
+
media_type="text/plain",
|
|
368
|
+
data=b"test",
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# =============================================================================
|
|
373
|
+
# Tests for file-like object support
|
|
374
|
+
# =============================================================================
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class TestFileLikeObject:
|
|
378
|
+
"""Tests for writing MCAP to file-like objects."""
|
|
379
|
+
|
|
380
|
+
def test_write_to_bytesio(self) -> None:
|
|
381
|
+
"""Test writing MCAP to a BytesIO buffer produces valid MCAP."""
|
|
382
|
+
buffer = BytesIO()
|
|
383
|
+
test_chan = Channel("test_bytesio", schema={"type": "object"})
|
|
384
|
+
|
|
385
|
+
with open_mcap(buffer):
|
|
386
|
+
for i in range(10):
|
|
387
|
+
test_chan.log({"value": i})
|
|
388
|
+
|
|
389
|
+
# Verify buffer has data with MCAP magic bytes
|
|
390
|
+
data = buffer.getvalue()
|
|
391
|
+
assert len(data) > 0
|
|
392
|
+
assert data[:8] == b"\x89MCAP0\r\n"
|
|
393
|
+
|
|
394
|
+
def test_bytesio_readable_by_mcap_reader(self) -> None:
|
|
395
|
+
"""Test that MCAP written to BytesIO can be read back."""
|
|
396
|
+
import mcap.reader
|
|
397
|
+
|
|
398
|
+
buffer = BytesIO()
|
|
399
|
+
test_chan = Channel("test_readable", schema={"type": "object"})
|
|
400
|
+
|
|
401
|
+
with open_mcap(buffer):
|
|
402
|
+
for i in range(5):
|
|
403
|
+
test_chan.log({"index": i})
|
|
404
|
+
|
|
405
|
+
# Read back and verify
|
|
406
|
+
buffer.seek(0)
|
|
407
|
+
reader = mcap.reader.make_reader(buffer)
|
|
408
|
+
summary = reader.get_summary()
|
|
409
|
+
|
|
410
|
+
assert summary is not None
|
|
411
|
+
assert summary.statistics is not None
|
|
412
|
+
assert summary.statistics.message_count == 5
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# =============================================================================
|
|
416
|
+
# Tests for disable_seeking option
|
|
417
|
+
# =============================================================================
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class NonSeekableWriter:
|
|
421
|
+
"""A file-like object that supports write/flush but raises on actual seeks.
|
|
422
|
+
|
|
423
|
+
This mimics a non-seekable stream like a pipe or network socket. It allows
|
|
424
|
+
position queries (seek(0, SEEK_CUR) or tell()) and no-op seeks to the current
|
|
425
|
+
position, but raises OSError on any seek that would change the position.
|
|
426
|
+
"""
|
|
427
|
+
|
|
428
|
+
def __init__(self) -> None:
|
|
429
|
+
self._buffer = BytesIO()
|
|
430
|
+
self._position = 0
|
|
431
|
+
|
|
432
|
+
def write(self, data: Union[bytes, bytearray]) -> int:
|
|
433
|
+
written = self._buffer.write(data)
|
|
434
|
+
self._position += written
|
|
435
|
+
return written
|
|
436
|
+
|
|
437
|
+
def flush(self) -> None:
|
|
438
|
+
self._buffer.flush()
|
|
439
|
+
|
|
440
|
+
def seek(self, offset: int, whence: int = SEEK_SET) -> int:
|
|
441
|
+
if whence == SEEK_CUR and offset == 0:
|
|
442
|
+
# Allow querying current position (tell())
|
|
443
|
+
return self._position
|
|
444
|
+
elif whence == SEEK_SET and offset == self._position:
|
|
445
|
+
# Allow no-op seek to current position
|
|
446
|
+
return self._position
|
|
447
|
+
else:
|
|
448
|
+
# Actual seeks that change position are not supported
|
|
449
|
+
raise OSError("Seeking is not supported")
|
|
450
|
+
|
|
451
|
+
def getvalue(self) -> bytes:
|
|
452
|
+
return self._buffer.getvalue()
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class TestDisableSeeking:
|
|
456
|
+
"""Tests for the disable_seeking option in MCAPWriteOptions."""
|
|
457
|
+
|
|
458
|
+
def test_disable_seeking_prevents_seek_calls(self) -> None:
|
|
459
|
+
"""Test that disable_seeking=True allows writing without seek calls."""
|
|
460
|
+
writer = NonSeekableWriter()
|
|
461
|
+
options = MCAPWriteOptions(disable_seeking=True)
|
|
462
|
+
test_chan = Channel("test_no_seek", schema={"type": "object"})
|
|
463
|
+
|
|
464
|
+
with open_mcap(writer, writer_options=options):
|
|
465
|
+
test_chan.log({"value": 1})
|
|
466
|
+
|
|
467
|
+
# Verify MCAP magic bytes are present
|
|
468
|
+
assert writer.getvalue()[:8] == b"\x89MCAP0\r\n"
|
|
469
|
+
|
|
470
|
+
def test_seeking_fails_without_disable_seeking(self) -> None:
|
|
471
|
+
"""Test that seeking is attempted by default and fails on non-seekable writer."""
|
|
472
|
+
writer = NonSeekableWriter()
|
|
473
|
+
test_chan = Channel("test_seek_default", schema={"type": "object"})
|
|
474
|
+
|
|
475
|
+
with pytest.raises(RuntimeError):
|
|
476
|
+
with open_mcap(writer):
|
|
477
|
+
test_chan.log({"value": 1})
|