Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions python/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]

## [v0.21.0] - September 4, 2026

### What's New

#### List and get data imports
Expand Down Expand Up @@ -38,6 +40,7 @@ CsvDataColumn(
Setting `enum_types` on a non-enum data type raises a validation error. Multi-channel Parquet single-channel-per-row imports don't support enums, since channel configs there are derived per row.

### Bugfixes
- Fix ingesting `ChannelDataType.BYTES` channels. Registering a flow with a bytes channel raised `ValueError: Unknown data type: bytes`, and sending a value on one raised `ValueError: Invalid data type: bytes`. Both paths now map to the bytes type in `sift-stream-bindings`. Values may be `bytes`, `bytearray`, `memoryview`, or an iterable of ints. Requires `sift-stream-bindings` 0.5.1.
- Fix `channels.get_data` printing a `UserWarning: Discarding nonzero nanoseconds in conversion` once per channel on each run-scoped fetch. The channel data cache stored segment bounds as `datetime`, which truncated the nanosecond timestamps the data already carries; they are now `pd.Timestamp`. `start_time` and `end_time` on `get_data` and `get_data_as_arrow` also accept a `pd.Timestamp` now, and its nanoseconds survive to the wire, where the service filters on them.

## [v0.20.0] - August 25, 2026
Expand Down
46 changes: 46 additions & 0 deletions python/lib/sift_client/_tests/sift_types/test_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
from sift_client.sift_types.channel import ChannelBitFieldElement, ChannelDataType
from sift_client.sift_types.ingestion import (
ChannelConfig,
ChannelValue,
FlowConfig,
IngestionConfig,
_to_rust_type,
_to_rust_value,
)


Expand Down Expand Up @@ -84,6 +86,50 @@ def test_to_rust_type_covers_every_channel_data_type(self):
assert _to_rust_type(ChannelDataType.BYTES) == ChannelDataTypePy.Bytes


class TestChannelValue:
"""Unit tests for ChannelValue conversion to the Rust bindings."""

@pytest.mark.parametrize(
"value",
[b"\xde\xad", bytearray(b"\xde\xad"), memoryview(b"\xde\xad"), [0xDE, 0xAD]],
)
def test_to_rust_form_bytes(self, value):
"""Test BYTES channel values reach the Rust Bytes variant, not BitField."""
channel_value = ChannelValue(name="payload", ty=ChannelDataType.BYTES, value=value)

value_py = channel_value._to_rust_form().value

assert value_py.is_bytes()
assert not value_py.is_bitfield()
assert bytes(value_py.as_bytes()) == b"\xde\xad"

def test_to_rust_form_bytes_none_is_empty(self):
"""Test a None BYTES value becomes an empty value."""
channel_value = ChannelValue(name="payload", ty=ChannelDataType.BYTES, value=None)

assert channel_value._to_rust_form().value.is_empty()

def test_to_rust_form_covers_every_channel_data_type(self):
"""Test _to_rust_form has a branch for every ChannelDataType."""
for data_type in ChannelDataType:
# A None value takes the Empty path but still exercises the data type branch.
channel_value = ChannelValue(name="c", ty=data_type, value=None)
assert channel_value._to_rust_form().value.is_empty()

def test_to_rust_value_bytes(self):
"""Test the flow-config value converter accepts BYTES channels."""
channel = ChannelConfig(name="payload", data_type=ChannelDataType.BYTES)

# Raises ValueError("Invalid data type: bytes") if the branch is missing.
assert _to_rust_value(channel, b"\xde\xad") is not None

def test_to_rust_value_string(self):
"""Test the flow-config value converter accepts STRING channels."""
channel = ChannelConfig(name="label", data_type=ChannelDataType.STRING)

assert _to_rust_value(channel, "hello") is not None


class TestFlowConfig:
"""Unit tests for FlowConfig model."""

Expand Down
7 changes: 7 additions & 0 deletions python/lib/sift_client/sift_types/ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,9 @@ def _to_rust_form(self):
value_py = ValuePy.Uint64(self.value)
elif self.ty == ChannelDataType.STRING:
value_py = ValuePy.String(self.value)
elif self.ty == ChannelDataType.BYTES:
# Accept bytes, bytearray, memoryview, or any iterable of ints.
value_py = ValuePy.Bytes(None if self.value is None else bytes(self.value))
else:
raise ValueError(f"Invalid data type: {self.ty}")

Expand Down Expand Up @@ -501,6 +504,10 @@ def _to_rust_value(channel: ChannelConfig, value: Any) -> IngestWithConfigDataCh
return IngestWithConfigDataChannelValuePy.uint32(value)
elif channel.data_type == ChannelDataType.UINT_64:
return IngestWithConfigDataChannelValuePy.uint64(value)
elif channel.data_type == ChannelDataType.STRING:
return IngestWithConfigDataChannelValuePy.string(value)
elif channel.data_type == ChannelDataType.BYTES:
return IngestWithConfigDataChannelValuePy.bytes(bytes(value))
else:
raise ValueError(f"Invalid data type: {channel.data_type}")

Expand Down
14 changes: 7 additions & 7 deletions python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "sift_stack_py"
version = "0.20.0"
version = "0.21.0"
description = "Python client library for the Sift API"
requires-python = ">=3.8"
readme = { file = "README.md", content-type = "text/markdown" }
Expand Down Expand Up @@ -69,7 +69,7 @@ all = [
'pyarrow>=17.0.0',
'pyulog~=1.2.2',
"rosbags~=0.0 ; python_full_version >= '3.8.2'",
'sift-stream-bindings==0.5.0',
'sift-stream-bindings==0.5.1',
'types-pyOpenSSL<24.0.0',
]
build = [
Expand Down Expand Up @@ -115,7 +115,7 @@ dev-all = [
'pyulog~=1.2.2',
"rosbags~=0.0 ; python_full_version >= '3.8.2'",
'ruff~=0.12.10',
'sift-stream-bindings==0.5.0',
'sift-stream-bindings==0.5.1',
'tomlkit~=0.13.3',
'types-pyOpenSSL<24.0.0',
]
Expand Down Expand Up @@ -173,7 +173,7 @@ docs-build = [
'pyulog~=1.2.2',
"rosbags~=0.0 ; python_full_version >= '3.8.2'",
'ruff~=0.12.10',
'sift-stream-bindings==0.5.0',
'sift-stream-bindings==0.5.1',
'tomlkit~=0.13.3',
'types-pyOpenSSL<24.0.0',
]
Expand All @@ -197,10 +197,10 @@ rosbags = [
"rosbags~=0.0 ; python_full_version >= '3.8.2'",
]
sift-stream = [
'sift-stream-bindings==0.5.0',
'sift-stream-bindings==0.5.1',
]
sift-stream-bindings = [
'sift-stream-bindings==0.5.0',
'sift-stream-bindings==0.5.1',
]
tdms = [
'npTDMS~=1.9',
Expand Down Expand Up @@ -248,7 +248,7 @@ docs = [
openssl = ["pyOpenSSL<24.0.0", "types-pyOpenSSL<24.0.0", "cffi~=1.14"]
tdms = ["npTDMS~=1.9"]
rosbags = ["rosbags~=0.0 ; python_full_version >= '3.8.2'"]
sift-stream = ["sift-stream-bindings==0.5.0"]
sift-stream = ["sift-stream-bindings==0.5.1"]
hdf5 = ["h5py~=3.11", "polars~=1.8"] # polars is only used by sift_py; remove once sift_py is fully deprecated
ulog = ["pyulog~=1.2.2"]
data-review = ["pyarrow>=17.0.0"]
Expand Down
Loading
Loading