Files
2026-07-13 13:05:14 +08:00

329 lines
16 KiB
Python

"""Tests for rr.send_dataframe and rr.send_record_batch."""
from __future__ import annotations
import uuid
from typing import TYPE_CHECKING
import pyarrow as pa
import pytest
import rerun as rr
from inline_snapshot import snapshot as inline_snapshot
from rerun import (
RERUN_KIND,
RERUN_KIND_CONTROL,
RERUN_KIND_INDEX,
SORBET_ARCHETYPE_NAME,
SORBET_COMPONENT,
SORBET_COMPONENT_TYPE,
SORBET_ENTITY_PATH,
SORBET_INDEX_NAME,
)
from rerun.experimental import RrdReader
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
from rerun.experimental import Chunk
from syrupy import SnapshotAssertion
APP_ID = "rerun_example_test_send_dataframe"
def _filter_rerun_columns(table: pa.Table) -> pa.Table:
"""Filter to only include columns with proper rerun metadata (skip rerun_segment_id)."""
cols_to_keep = []
for field in table.schema:
if field.name == "log_time":
# changes every run
continue
if field.metadata is None or b"rerun:kind" not in field.metadata:
continue
cols_to_keep.append(field.name)
return table.select(cols_to_keep)
def test_send_dataframe_roundtrip(tmp_path: Path, snapshot: SnapshotAssertion) -> None:
"""Test that send_dataframe can roundtrip data through Server + Catalog API."""
original_dir = tmp_path / "original"
original_dir.mkdir()
rrd_path = original_dir / "recording.rrd"
# Create initial recording with some data
with rr.RecordingStream(APP_ID, recording_id=uuid.uuid4()) as rec:
rec.save(str(rrd_path))
rec.set_time("my_index", sequence=1)
rec.log("points", rr.Points3D([[1, 2, 3], [4, 5, 6], [7, 8, 9]], radii=[0.5]))
rec.set_time("my_index", sequence=7)
rec.log("points", rr.Points3D([[10, 11, 12]], colors=[[255, 0, 0]]))
# Load via Server + Catalog API and read as Arrow table
with rr.server.Server(datasets={"test_dataset": original_dir}) as server:
ds = server.client().get_dataset("test_dataset")
original_table = _filter_rerun_columns(ds.reader(index="my_index").to_arrow_table())
# Send via send_dataframe to a new recording
roundtrip_dir = tmp_path / "roundtrip"
roundtrip_dir.mkdir()
rrd2_path = roundtrip_dir / "recording.rrd"
with rr.RecordingStream(APP_ID + "_roundtrip", recording_id=uuid.uuid4()) as rec2:
rec2.save(str(rrd2_path))
rr.send_dataframe(original_table, recording=rec2)
# Verify roundtrip via catalog API - data should be identical
with rr.server.Server(datasets={"roundtrip_dataset": roundtrip_dir}) as server:
ds = server.client().get_dataset("roundtrip_dataset")
roundtrip_table = _filter_rerun_columns(ds.reader(index="my_index").to_arrow_table())
assert original_table == roundtrip_table
assert str(original_table) == snapshot()
# A simple list-of-floats component column, two rows.
_VALUES = pa.array([[1.0], [2.0]], type=pa.list_(pa.float32()))
@pytest.fixture
def send_dataframe_and_get_chunks(tmp_path: Path) -> Callable[..., list[Chunk]]:
"""Send a table/reader via `send_dataframe`, then read the result back as sorted chunks."""
counter = 0
def _impl(df: pa.Table | pa.RecordBatchReader, **kwargs: object) -> list[Chunk]:
nonlocal counter
counter += 1
out_path = tmp_path / f"out_{counter}.rrd"
with rr.RecordingStream(APP_ID, recording_id="characterization", send_properties=False) as rec:
rec.save(out_path)
rr.send_dataframe(df, recording=rec, **kwargs) # type: ignore[arg-type]
chunks = RrdReader(out_path).stream().to_chunks()
return sorted(chunks, key=lambda c: c.entity_path)
return _impl
def _summary(chunks: list[Chunk]) -> list[str]:
"""One compact, redacted line per chunk — entity path, timelines, and components."""
return [
f"{c.entity_path} static={c.is_static} timelines={sorted(c.timeline_names)} ncols={c.num_columns}"
for c in chunks
]
def test_full_metadata_single_entity(
send_dataframe_and_get_chunks: Callable[[pa.Table | pa.RecordBatchReader], list[Chunk]],
) -> None:
"""Fully-tagged index + component column, mirroring the `send_dataframe` doc snippet."""
index = pa.array([0, 1], type=pa.int64())
schema = pa.schema([
pa.field("frame", index.type, metadata={SORBET_INDEX_NAME: b"frame", RERUN_KIND: RERUN_KIND_INDEX}),
pa.field(
"/points:Points3D:positions",
_VALUES.type,
metadata={
SORBET_ENTITY_PATH: b"/points",
SORBET_ARCHETYPE_NAME: b"rerun.archetypes.Points3D",
SORBET_COMPONENT: b"Points3D:positions",
SORBET_COMPONENT_TYPE: b"rerun.components.Position3D",
RERUN_KIND: b"data",
},
),
])
[chunk] = send_dataframe_and_get_chunks(pa.Table.from_arrays([index, _VALUES], schema=schema))
assert chunk.format(redact=True) == inline_snapshot("""\
┌───────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ METADATA: │
│ * entity_path: /points │
│ * id: [**REDACTED**] │
│ * version: [**REDACTED**] │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ ┌───────────────────────────────────────────────┬───────────────────┬───────────────────────────────┐ │
│ │ RowId ┆ frame ┆ Points3D:positions │ │
│ │ --- ┆ --- ┆ --- │ │
│ │ type: non-null FixedSizeBinary(16) ┆ type: Int64 ┆ type: List(Float32) │ │
│ │ ARROW:extension:metadata: {"namespace":"row"} ┆ index_name: frame ┆ archetype: Points3D │ │
│ │ ARROW:extension:name: TUID ┆ is_sorted: true ┆ component: Points3D:positions │ │
│ │ is_sorted: true ┆ kind: index ┆ component_type: Position3D │ │
│ │ kind: control ┆ ┆ kind: data │ │
│ ╞═══════════════════════════════════════════════╪═══════════════════╪═══════════════════════════════╡ │
│ │ row_[**REDACTED**] ┆ 0 ┆ [1.0] │ │
│ ├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ │
│ │ row_[**REDACTED**] ┆ 1 ┆ [2.0] │ │
│ └───────────────────────────────────────────────┴───────────────────┴───────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────────────────────────────────────┘\
""")
def test_index_kind_without_index_name(
send_dataframe_and_get_chunks: Callable[[pa.Table | pa.RecordBatchReader], list[Chunk]],
) -> None:
"""A `kind=index` column with no `index_name` becomes a timeline named after the column."""
index = pa.array([0, 1], type=pa.int64())
schema = pa.schema([
pa.field("my_time", index.type, metadata={RERUN_KIND: RERUN_KIND_INDEX}),
pa.field(
"/e:C:c", _VALUES.type, metadata={SORBET_ENTITY_PATH: b"/e", SORBET_COMPONENT: b"C:c", RERUN_KIND: b"data"}
),
])
[chunk] = send_dataframe_and_get_chunks(pa.Table.from_arrays([index, _VALUES], schema=schema))
assert chunk.timeline_names == inline_snapshot(["my_time"])
def test_entity_path_from_column_name(
send_dataframe_and_get_chunks: Callable[[pa.Table | pa.RecordBatchReader], list[Chunk]],
) -> None:
"""A leading-`/` column name is split into entity path + component; otherwise it lands on root."""
index = pa.array([0, 1], type=pa.int64())
schema = pa.schema([
pa.field("frame", index.type, metadata={SORBET_INDEX_NAME: b"frame", RERUN_KIND: RERUN_KIND_INDEX}),
pa.field("/points:Points3D:positions", _VALUES.type, metadata={SORBET_COMPONENT: b"Points3D:positions"}),
])
[chunk] = send_dataframe_and_get_chunks(pa.Table.from_arrays([index, _VALUES], schema=schema))
assert chunk.entity_path == inline_snapshot("/points")
def test_entity_path_non_leading_slash_is_root(
send_dataframe_and_get_chunks: Callable[[pa.Table | pa.RecordBatchReader], list[Chunk]],
) -> None:
"""A column name without a leading `/` is no longer parsed for an entity path; it lands on root."""
index = pa.array([0, 1], type=pa.int64())
schema = pa.schema([
pa.field("frame", index.type, metadata={SORBET_INDEX_NAME: b"frame", RERUN_KIND: RERUN_KIND_INDEX}),
pa.field("foo:bar", _VALUES.type, metadata={}),
])
[chunk] = send_dataframe_and_get_chunks(pa.Table.from_arrays([index, _VALUES], schema=schema))
assert chunk.entity_path == inline_snapshot("/")
def test_multiple_entities(
send_dataframe_and_get_chunks: Callable[[pa.Table | pa.RecordBatchReader], list[Chunk]],
) -> None:
"""Component columns with different entity paths split into one chunk per entity."""
index = pa.array([0, 1], type=pa.int64())
schema = pa.schema([
pa.field("frame", index.type, metadata={SORBET_INDEX_NAME: b"frame", RERUN_KIND: RERUN_KIND_INDEX}),
pa.field(
"/a:C:c", _VALUES.type, metadata={SORBET_ENTITY_PATH: b"/a", SORBET_COMPONENT: b"C:c", RERUN_KIND: b"data"}
),
pa.field(
"/b:C:c", _VALUES.type, metadata={SORBET_ENTITY_PATH: b"/b", SORBET_COMPONENT: b"C:c", RERUN_KIND: b"data"}
),
])
chunks = send_dataframe_and_get_chunks(pa.Table.from_arrays([index, _VALUES, _VALUES], schema=schema))
assert _summary(chunks) == inline_snapshot([
"/a static=False timelines=['frame'] ncols=3",
"/b static=False timelines=['frame'] ncols=3",
])
def test_control_kind_is_treated_as_row_id() -> None:
"""
A `kind=control` column is interpreted as a row-id column, not a component.
Without a chunk id the batch is only *partially* identified, so it takes the mint path: the
control column is dropped (rather than carried as a component) and fresh row ids are minted.
"""
from rerun.experimental import Chunk
index = pa.array([0, 1], type=pa.int64())
control = pa.array([10, 20], type=pa.int64())
schema = pa.schema([
pa.field("frame", index.type, metadata={SORBET_INDEX_NAME: b"frame", RERUN_KIND: RERUN_KIND_INDEX}),
pa.field("ctrl", control.type, metadata={RERUN_KIND: RERUN_KIND_CONTROL}),
pa.field(
"/e:C:c", _VALUES.type, metadata={SORBET_ENTITY_PATH: b"/e", SORBET_COMPONENT: b"C:c", RERUN_KIND: b"data"}
),
])
rb = pa.RecordBatch.from_arrays([index, control, _VALUES], schema=schema)
[chunk] = Chunk.from_record_batch(rb)
formatted = chunk.format(redact=True, trim_metadata_keys=False)
# The control column was consumed as a row-id and dropped, not carried as a component, and the
# chunk carries a freshly-minted `RowId` column instead.
assert "ctrl" not in formatted
assert "rerun:component: C:c" in formatted
assert "RowId" in formatted
def test_no_component_type_is_left_unset(
send_dataframe_and_get_chunks: Callable[[pa.Table | pa.RecordBatchReader], list[Chunk]],
) -> None:
"""A component column with no `component_type` metadata leaves it unset (no `Unknown` default)."""
index = pa.array([0, 1], type=pa.int64())
schema = pa.schema([
pa.field("frame", index.type, metadata={SORBET_INDEX_NAME: b"frame", RERUN_KIND: RERUN_KIND_INDEX}),
pa.field(
"/e:thing",
_VALUES.type,
metadata={SORBET_ENTITY_PATH: b"/e", SORBET_COMPONENT: b"thing", RERUN_KIND: b"data"},
),
])
[chunk] = send_dataframe_and_get_chunks(pa.Table.from_arrays([index, _VALUES], schema=schema))
formatted = chunk.format(redact=True, trim_metadata_keys=False)
assert "rerun:component: thing" in formatted
assert "rerun:component_type" not in formatted
def test_no_index_is_ambiguous() -> None:
"""With `index` left at the default (AUTO) and no index metadata, the batch is rejected."""
from rerun.experimental import Chunk
schema = pa.schema([
pa.field(
"/e:C:c", _VALUES.type, metadata={SORBET_ENTITY_PATH: b"/e", SORBET_COMPONENT: b"C:c", RERUN_KIND: b"data"}
),
])
with pytest.raises(ValueError):
Chunk.from_record_batch(pa.RecordBatch.from_arrays([_VALUES], schema=schema))
def test_static_index_none(
send_dataframe_and_get_chunks: Callable[..., list[Chunk]],
) -> None:
"""`index=None` produces a static chunk."""
one_value = pa.array([[1.0]], type=pa.list_(pa.float32()))
schema = pa.schema([
pa.field(
"/e:C:c",
one_value.type,
metadata={SORBET_ENTITY_PATH: b"/e", SORBET_COMPONENT: b"C:c", RERUN_KIND: b"data"},
),
])
table = pa.Table.from_arrays([one_value], schema=schema)
[chunk] = send_dataframe_and_get_chunks(table, index=None)
assert chunk.is_static == inline_snapshot(True)
assert chunk.timeline_names == inline_snapshot([])
def test_static_index_none_with_index_metadata_is_contradiction(
send_dataframe_and_get_chunks: Callable[..., list[Chunk]],
) -> None:
"""`index=None` plus index metadata in the batch is a contradiction and is rejected."""
index = pa.array([0, 1], type=pa.int64())
schema = pa.schema([
pa.field("frame", index.type, metadata={SORBET_INDEX_NAME: b"frame", RERUN_KIND: RERUN_KIND_INDEX}),
pa.field(
"/e:C:c", _VALUES.type, metadata={SORBET_ENTITY_PATH: b"/e", SORBET_COMPONENT: b"C:c", RERUN_KIND: b"data"}
),
])
with pytest.raises(ValueError):
send_dataframe_and_get_chunks(pa.Table.from_arrays([index, _VALUES], schema=schema), index=None)
def test_record_batch_reader_input(
send_dataframe_and_get_chunks: Callable[[pa.Table | pa.RecordBatchReader], list[Chunk]],
) -> None:
"""A `RecordBatchReader` produces the same result as the equivalent `Table`."""
index = pa.array([0, 1], type=pa.int64())
schema = pa.schema([
pa.field("frame", index.type, metadata={SORBET_INDEX_NAME: b"frame", RERUN_KIND: RERUN_KIND_INDEX}),
pa.field(
"/e:C:c", _VALUES.type, metadata={SORBET_ENTITY_PATH: b"/e", SORBET_COMPONENT: b"C:c", RERUN_KIND: b"data"}
),
])
table = pa.Table.from_arrays([index, _VALUES], schema=schema)
[chunk] = send_dataframe_and_get_chunks(table.to_reader())
assert _summary([chunk]) == inline_snapshot(["/e static=False timelines=['frame'] ncols=3"])