Files
wehub-resource-sync ee3943b5b1
CI / test (deps-minimum, windows-latest, unit) (push) Blocked by required conditions
CI / test_py314 (deps-latest, ubuntu-latest, unit) (push) Blocked by required conditions
CI / test_py314 (deps-latest, windows-latest, unit) (push) Blocked by required conditions
CI / test_py314_future (deps-latest, ubuntu-latest, unit) (push) Blocked by required conditions
CI / test_py314_future (deps-latest, windows-latest, unit) (push) Blocked by required conditions
Secret Leaks / trufflehog (push) Waiting to run
CI / check_code_quality (push) Waiting to run
CI / test (deps-latest, ubuntu-latest, integration) (push) Blocked by required conditions
CI / test (deps-latest, ubuntu-latest, unit) (push) Blocked by required conditions
CI / test (deps-latest, windows-latest, integration) (push) Blocked by required conditions
CI / test (deps-latest, windows-latest, unit) (push) Blocked by required conditions
CI / test (deps-minimum, ubuntu-latest, integration) (push) Blocked by required conditions
CI / test (deps-minimum, ubuntu-latest, unit) (push) Blocked by required conditions
CI / test (deps-minimum, windows-latest, integration) (push) Blocked by required conditions
Build documentation / build (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 13:24:32 +08:00

749 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the per-device wide-format TsFile builder."""
from __future__ import annotations
import logging
from datetime import date, datetime, timedelta, timezone
from typing import Any, Sequence
import pyarrow as pa
import pytest
# `tsfile` requires pyarrow<20 for python<3.14, which conflicts with datasets'
# pyarrow>=21.0.0. It is therefore only installed in the py3.14 CI. Skip this
# whole module (at collection time) when tsfile is not importable.
pytest.importorskip("tsfile")
from tsfile import ColumnCategory, ColumnSchema, TableSchema, Tablet, TsFileWriter # noqa: E402
from tsfile.constants import TSDataType # noqa: E402
from datasets import IterableDataset, load_dataset # noqa: E402
from datasets.builder import InvalidConfigName # noqa: E402
from datasets.data_files import DataFilesList # noqa: E402
from datasets.packaged_modules.tsfile.tsfile import TsFileConfig, _to_epoch # noqa: E402
# ---------------------------------------------------------------------------
# Time-base constants
# ---------------------------------------------------------------------------
#
# Every fixture's timestamps live in a disjoint epoch-ms slice off ``T0`` so
# that, when two files of the same device are merged, the resulting
# time-sorted order is fully determined by writer-side timestamps. This lets
# the assertions below check both *content* and *order* unambiguously.
T0 = 1_700_000_000_000 # base: single_device, multi_device, all_types
T_EVOLVED = T0 + 500_000 # evolved file (after single_device's 5 points)
T_INT32 = T0 + 1_000_000
T_INT64 = T0 + 2_000_000
T_FLOAT = T0 + 3_000_000
# ---------------------------------------------------------------------------
# Generic writer
# ---------------------------------------------------------------------------
# A row maps column name -> Python value, plus a special "time" -> int (epoch).
Row = dict[str, Any]
ColumnSpec = tuple[str, TSDataType, ColumnCategory]
def _write_tsfile(path: str, tables: Sequence[tuple[str, Sequence[ColumnSpec], Sequence[Sequence[Row]]]]) -> None:
"""Write one or more tables, each as one or more tablets, to ``path``.
Each ``tables`` entry is ``(table_name, columns, tablets)`` where:
- ``columns`` is the table schema as ``[(name, TSDataType, ColumnCategory), ...]``;
it must include exactly one TIME column called ``"time"``.
- ``tablets`` is a list of tablets; each tablet is a list of row dicts. A
row dict must carry ``"time"`` plus every TAG/FIELD column in the table.
"""
writer = TsFileWriter(path)
try:
# Register schemas first so multiple-table files validate up-front.
for table_name, columns, _ in tables:
writer.register_table(TableSchema(table_name, [ColumnSchema(*c) for c in columns]))
for table_name, columns, tablets in tables:
non_time = [(n, t) for (n, t, c) in columns if c != ColumnCategory.TIME]
col_names = [n for n, _ in non_time]
col_types = [t for _, t in non_time]
for rows in tablets:
tablet = Tablet(col_names, col_types, len(rows))
tablet.set_table_name(table_name)
for i, row in enumerate(rows):
tablet.add_timestamp(i, row["time"])
for name in col_names:
tablet.add_value_by_name(name, i, row[name])
writer.write_table(tablet)
finally:
writer.close()
# ---------------------------------------------------------------------------
# Per-fixture writers (each declarative + tiny)
# ---------------------------------------------------------------------------
def _write_single_device(path: str) -> None:
"""One device 'd1', two DOUBLE fields, 5 points starting at T0."""
cols = [
("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
("device", TSDataType.STRING, ColumnCategory.TAG),
("temperature", TSDataType.DOUBLE, ColumnCategory.FIELD),
("humidity", TSDataType.DOUBLE, ColumnCategory.FIELD),
]
rows = [{"time": T0 + i * 1000, "device": "d1", "temperature": 20.0 + i, "humidity": 50.0 + i} for i in range(5)]
_write_tsfile(path, [("mytable", cols, [rows])])
def _write_multi_device(path: str) -> None:
"""Three devices, 3 points each, all sharing the same field schema."""
cols = [
("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
("device", TSDataType.STRING, ColumnCategory.TAG),
("temperature", TSDataType.DOUBLE, ColumnCategory.FIELD),
("humidity", TSDataType.DOUBLE, ColumnCategory.FIELD),
]
tablets = [
[{"time": T0 + i * 1000, "device": dev, "temperature": 10.0 + i, "humidity": 50.0 + i} for i in range(3)]
for dev in ("d1", "d2", "d3")
]
_write_tsfile(path, [("plant", cols, tablets)])
def _write_evolved(path: str) -> None:
"""Same table+device as single_device, plus a new ``voltage`` field."""
cols = [
("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
("device", TSDataType.STRING, ColumnCategory.TAG),
("temperature", TSDataType.DOUBLE, ColumnCategory.FIELD),
("humidity", TSDataType.DOUBLE, ColumnCategory.FIELD),
("voltage", TSDataType.DOUBLE, ColumnCategory.FIELD),
]
rows = [
{
"time": T_EVOLVED + i * 1000,
"device": "d1",
"temperature": 30.0 + i,
"humidity": 60.0 + i,
"voltage": 220.0 + i,
}
for i in range(3)
]
_write_tsfile(path, [("mytable", cols, [rows])])
def _write_numeric_field(path: str, ts_type: TSDataType, base_ts: int, value_fn) -> None:
"""One device 'd1' with a single numeric ``temperature`` field."""
cols = [
("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
("device", TSDataType.STRING, ColumnCategory.TAG),
("temperature", ts_type, ColumnCategory.FIELD),
]
rows = [{"time": base_ts + i * 1000, "device": "d1", "temperature": value_fn(i)} for i in range(3)]
_write_tsfile(path, [("mytable", cols, [rows])])
def _write_two_tables(path: str) -> None:
"""Two distinct tables in one file: ``table_a`` (registered first) and ``table_b``."""
a_cols = [
("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
("device", TSDataType.STRING, ColumnCategory.TAG),
("a", TSDataType.DOUBLE, ColumnCategory.FIELD),
]
b_cols = [
("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
("device", TSDataType.STRING, ColumnCategory.TAG),
("b", TSDataType.DOUBLE, ColumnCategory.FIELD),
]
a_rows = [{"time": 1_000 + i, "device": "d1", "a": float(i)} for i in range(2)]
b_rows = [{"time": 2_000 + i, "device": "d1", "b": 100.0 + i} for i in range(2)]
_write_tsfile(path, [("table_a", a_cols, [a_rows]), ("table_b", b_cols, [b_rows])])
def _write_all_types(path: str) -> None:
"""Every supported TSDataType represented as a FIELD."""
cols = [
("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
("tag", TSDataType.STRING, ColumnCategory.TAG),
("col_boolean", TSDataType.BOOLEAN, ColumnCategory.FIELD),
("col_int32", TSDataType.INT32, ColumnCategory.FIELD),
("col_int64", TSDataType.INT64, ColumnCategory.FIELD),
("col_float", TSDataType.FLOAT, ColumnCategory.FIELD),
("col_double", TSDataType.DOUBLE, ColumnCategory.FIELD),
("col_text", TSDataType.TEXT, ColumnCategory.FIELD),
("col_string", TSDataType.STRING, ColumnCategory.FIELD),
("col_timestamp", TSDataType.TIMESTAMP, ColumnCategory.FIELD),
("col_date", TSDataType.DATE, ColumnCategory.FIELD),
("col_blob", TSDataType.BLOB, ColumnCategory.FIELD),
]
rows = [
{
"time": T0 + i * 1000,
"tag": "d1",
"col_boolean": i % 2 == 0,
"col_int32": 100 + i,
"col_int64": 1_000_000 + i,
"col_float": 1.5 + i,
"col_double": 100.5 + i,
"col_text": f"text_{i}",
"col_string": f"str_{i}",
"col_timestamp": 1_600_000_000_000 + i * 1000,
"col_date": date(2024, 1, 1 + i),
"col_blob": f"blob{i}".encode(),
}
for i in range(3)
]
_write_tsfile(path, [("alltypes", cols, [rows])])
def _write_large_device(path: str, n_points: int = 200) -> None:
"""Single device with many points, used to exercise multi-batch concat."""
cols = [
("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
("device", TSDataType.STRING, ColumnCategory.TAG),
("v", TSDataType.INT64, ColumnCategory.FIELD),
]
rows = [{"time": T0 + i, "device": "d1", "v": i} for i in range(n_points)]
_write_tsfile(path, [("mytable", cols, [rows])])
def _write_two_devices_subset(path: str, devices: Sequence[str], base_ts: int) -> None:
"""A multi-device fixture used to assemble cross-file device sets."""
cols = [
("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
("device", TSDataType.STRING, ColumnCategory.TAG),
("v", TSDataType.DOUBLE, ColumnCategory.FIELD),
]
tablets = [[{"time": base_ts + i * 1000, "device": dev, "v": float(i)} for i in range(3)] for dev in devices]
_write_tsfile(path, [("mytable", cols, tablets)])
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def make_tsfile(tmp_path):
"""Factory fixture: ``make_tsfile("name", writer_fn, *args, **kwargs)``."""
def _make(name: str, writer_fn, *args, **kwargs) -> str:
p = str(tmp_path / f"{name}.tsfile")
writer_fn(p, *args, **kwargs)
return p
return _make
@pytest.fixture
def tsfile_path(make_tsfile):
return make_tsfile("sample", _write_single_device)
@pytest.fixture
def multi_device_tsfile_path(make_tsfile):
return make_tsfile("multi", _write_multi_device)
@pytest.fixture
def evolved_tsfile_path(make_tsfile):
return make_tsfile("evolved", _write_evolved)
@pytest.fixture
def two_tables_tsfile_path(make_tsfile):
return make_tsfile("two_tables", _write_two_tables)
@pytest.fixture
def all_types_tsfile_path(make_tsfile):
return make_tsfile("alltypes", _write_all_types)
# ---------------------------------------------------------------------------
# Config-level
# ---------------------------------------------------------------------------
def test_config_raises_when_invalid_name():
with pytest.raises(InvalidConfigName, match="Bad characters"):
TsFileConfig(name="name-with-*-invalid-character")
@pytest.mark.parametrize("data_files", ["str_path", ["str_path"], DataFilesList(["str_path"], [()])])
def test_config_raises_when_invalid_data_files(data_files):
with pytest.raises(ValueError, match="Expected a DataFilesDict"):
TsFileConfig(name="name", data_files=data_files)
@pytest.mark.parametrize(
"kwargs, match",
[
({"input_batch_size": 0}, "input_batch_size"),
({"output_batch_size": 0}, "output_batch_size"),
({"columns": []}, "non-empty"),
({"timestamp_unit": "minute"}, "timestamp_unit"),
({"on_bad_files": "boom"}, "on_bad_files"),
],
)
def test_config_rejects_invalid_values(kwargs, match):
with pytest.raises(ValueError, match=match):
TsFileConfig(name="x", **kwargs)
def test_config_normalizes_time_bounds():
cfg = TsFileConfig(
name="x",
start_time=pa.scalar(1500, type=pa.timestamp("ms")),
end_time=2000,
)
assert cfg.start_time == 1500
assert cfg.end_time == 2000
# ---------------------------------------------------------------------------
# _to_epoch unit tests
# ---------------------------------------------------------------------------
def test_to_epoch_int_passthrough():
assert _to_epoch(1234, "ms") == 1234
def test_to_epoch_naive_datetime():
assert _to_epoch(datetime(1970, 1, 1, 0, 0, 1), "ms") == 1000
@pytest.mark.parametrize(
"aware",
[
datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone(timedelta(hours=8))),
"2024-01-01T00:00:00+08:00",
],
ids=["datetime", "iso_string"],
)
def test_to_epoch_aware_inputs_normalized_to_utc(aware):
# 2024-01-01T00:00:00 in UTC+8 == 2023-12-31T16:00:00 UTC.
naive_utc = datetime(2023, 12, 31, 16, 0, 0)
assert _to_epoch(aware, "ms") == _to_epoch(naive_utc, "ms")
def test_to_epoch_date():
assert _to_epoch(date(1970, 1, 2), "ms") == 86_400_000
def test_to_epoch_iso_string():
assert _to_epoch("1970-01-01T00:00:01", "ms") == 1000
def test_to_epoch_pa_scalar():
assert _to_epoch(pa.scalar(1500, type=pa.timestamp("ms")), "ms") == 1500
def test_to_epoch_rejects_bool():
with pytest.raises(TypeError, match="bool"):
_to_epoch(True, "ms")
@pytest.mark.parametrize("value", [object(), b"bytes", "not-a-date"])
def test_to_epoch_rejects_garbage(value):
with pytest.raises(TypeError, match="must be a"):
_to_epoch(value, "ms")
# ---------------------------------------------------------------------------
# End-to-end: single device, full table
# ---------------------------------------------------------------------------
def test_load_full_table(tsfile_path):
ds = load_dataset("tsfile", data_files=tsfile_path)["train"]
# One row per device. TAG = scalar string; time + fields = lists.
assert ds.column_names == ["device", "time", "temperature", "humidity"]
assert len(ds) == 1
row = ds[0]
assert row["device"] == "d1"
assert len(row["time"]) == 5
assert row["time"][0] == datetime(2023, 11, 14, 22, 13, 20)
assert row["time"][-1] == datetime(2023, 11, 14, 22, 13, 24)
assert row["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0]
assert row["humidity"] == [50.0, 51.0, 52.0, 53.0, 54.0]
def test_load_with_field_subset(tsfile_path):
ds = load_dataset("tsfile", data_files=tsfile_path, columns=["temperature"])["train"]
assert ds.column_names == ["device", "time", "temperature"]
assert ds[0]["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0]
def test_columns_are_lowercased(tsfile_path):
ds = load_dataset("tsfile", data_files=tsfile_path, columns=["TEMPERATURE", "Humidity"])["train"]
assert ds.column_names == ["device", "time", "temperature", "humidity"]
def test_columns_request_tag_is_silently_ignored(tsfile_path):
"""Passing a TAG name in `columns` is a no-op (TAGs are always emitted)."""
ds = load_dataset("tsfile", data_files=tsfile_path, columns=["device", "temperature"])["train"]
assert ds.column_names == ["device", "time", "temperature"]
assert ds.features["device"].dtype == "string"
assert ds.features["temperature"].feature.dtype == "float64"
assert ds["device"] == ["d1"]
assert ds[0]["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0]
def test_columns_request_time_is_silently_ignored(tsfile_path):
"""Passing the TIME column name in `columns` is a no-op (TIME is always emitted)."""
ds = load_dataset("tsfile", data_files=tsfile_path, columns=["time", "temperature"])["train"]
# `time` should appear exactly once, and as the real timestamp list — not
# as a duplicate all-null float64 list column.
assert ds.column_names == ["device", "time", "temperature"]
assert ds.features["time"].feature.dtype.startswith("timestamp")
row = ds[0]
assert len(row["time"]) == 5
assert row["time"][0] == datetime(2023, 11, 14, 22, 13, 20)
assert row["time"][-1] == datetime(2023, 11, 14, 22, 13, 24)
assert row["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0]
def test_columns_request_only_time(tsfile_path):
"""`columns=["time"]` should still produce TAG + TIME, with no FIELD list columns."""
ds = load_dataset("tsfile", data_files=tsfile_path, columns=["time"])["train"]
assert ds.column_names == ["device", "time"]
assert ds.features["time"].feature.dtype.startswith("timestamp")
row = ds[0]
assert row["device"] == "d1"
assert len(row["time"]) == 5
assert row["time"][0] == datetime(2023, 11, 14, 22, 13, 20)
assert row["time"][-1] == datetime(2023, 11, 14, 22, 13, 24)
def test_columns_unknown_field_filled_with_null(tsfile_path):
ds = load_dataset(
"tsfile",
data_files=tsfile_path,
columns=["temperature", "voltage"], # voltage is absent
)["train"]
assert ds.column_names == ["device", "time", "temperature", "voltage"]
row = ds[0]
assert row["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0]
assert row["voltage"] == [None] * 5
def test_columns_all_unknown_still_returns_time_and_tags(tsfile_path):
ds = load_dataset(
"tsfile",
data_files=tsfile_path,
columns=["nonexistent_a", "nonexistent_b"],
)["train"]
assert ds.column_names == ["device", "time", "nonexistent_a", "nonexistent_b"]
row = ds[0]
assert row["device"] == "d1"
assert len(row["time"]) == 5
assert row["nonexistent_a"] == [None] * 5
assert row["nonexistent_b"] == [None] * 5
# ---------------------------------------------------------------------------
# Time-range filtering
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"start, end",
[
# pa.scalar from datetime
(
pa.scalar(datetime(2023, 11, 14, 22, 13, 21), type=pa.timestamp("ms")),
pa.scalar(datetime(2023, 11, 14, 22, 13, 23), type=pa.timestamp("ms")),
),
# pa.scalar from int epoch
(
pa.scalar(T0 + 1000, type=pa.timestamp("ms")),
pa.scalar(T0 + 3000, type=pa.timestamp("ms")),
),
# plain int epoch
(T0 + 1000, T0 + 3000),
# datetime
(datetime(2023, 11, 14, 22, 13, 21), datetime(2023, 11, 14, 22, 13, 23)),
# ISO-8601 string
("2023-11-14T22:13:21", "2023-11-14T22:13:23"),
],
)
def test_load_with_time_range_inputs(tsfile_path, start, end):
ds = load_dataset("tsfile", data_files=tsfile_path, start_time=start, end_time=end)["train"]
assert len(ds[0]["time"]) == 3
# ---------------------------------------------------------------------------
# Multi-device & cross-file folding
# ---------------------------------------------------------------------------
def test_load_multi_device_one_row_per_device(multi_device_tsfile_path):
ds = load_dataset("tsfile", data_files=multi_device_tsfile_path)["train"]
assert len(ds) == 3
assert sorted(ds["device"]) == ["d1", "d2", "d3"]
for row in ds:
assert len(row["time"]) == 3
assert row["temperature"] == [10.0, 11.0, 12.0]
assert row["humidity"] == [50.0, 51.0, 52.0]
def test_schema_evolution_merges_same_device(tsfile_path, evolved_tsfile_path):
"""Same device d1 in two files → one row, lists merged in time order."""
ds = load_dataset("tsfile", data_files=[tsfile_path, evolved_tsfile_path])["train"]
assert "voltage" in ds.column_names
assert len(ds) == 1
row = ds[0]
assert row["device"] == "d1"
# 5 (old) + 3 (new) points, fully time-ordered.
assert len(row["time"]) == 8
# Old file lacked `voltage` → null on its 5 points; new file fills the rest.
assert row["voltage"] == [None] * 5 + [220.0, 221.0, 222.0]
# `temperature` is present in both files, contiguous in the merged order.
assert row["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0, 30.0, 31.0, 32.0]
def test_multi_file_multi_device_partial_overlap(make_tsfile):
"""Two files × two devices each, with one device shared.
File A: devices {d1, d2}; file B: devices {d2, d3}. The merged dataset
must have 3 rows (one per unique device), and d2 must have *6* points
(3 from each file) sorted by time.
"""
fa = make_tsfile("a", _write_two_devices_subset, devices=["d1", "d2"], base_ts=T0)
fb = make_tsfile("b", _write_two_devices_subset, devices=["d2", "d3"], base_ts=T0 + 100_000)
ds = load_dataset("tsfile", data_files=[fa, fb])["train"]
by_dev = {row["device"]: row for row in ds}
assert set(by_dev) == {"d1", "d2", "d3"}
assert len(by_dev["d1"]["time"]) == 3
assert len(by_dev["d3"]["time"]) == 3
# Shared device gets all 6 points, time-sorted (file A first, then B).
assert len(by_dev["d2"]["time"]) == 6
assert by_dev["d2"]["v"] == [0.0, 1.0, 2.0, 0.0, 1.0, 2.0]
# ---------------------------------------------------------------------------
# Type promotion across files
# ---------------------------------------------------------------------------
def test_type_promotion_int32_to_int64(make_tsfile):
int32_path = make_tsfile("narrow", _write_numeric_field, TSDataType.INT32, T_INT32, lambda i: 10 + i)
int64_path = make_tsfile("wide", _write_numeric_field, TSDataType.INT64, T_INT64, lambda i: 1_000_000 + i)
ds = load_dataset("tsfile", data_files=[int32_path, int64_path])["train"]
assert len(ds) == 1
assert ds.features["temperature"].feature.dtype == "int64"
# int32 timestamps come earlier (T_INT32 < T_INT64).
assert ds[0]["temperature"] == [10, 11, 12, 1_000_000, 1_000_001, 1_000_002]
def test_type_promotion_float_to_double(make_tsfile):
float_path = make_tsfile("narrow", _write_numeric_field, TSDataType.FLOAT, T_FLOAT, lambda i: 1.5 + i)
double_path = make_tsfile("wide", _write_single_device)
ds = load_dataset("tsfile", data_files=[float_path, double_path])["train"]
assert len(ds) == 1
assert ds.features["temperature"].feature.dtype == "float64"
# double fixture lives at T0..T0+4s; float at T_FLOAT (later).
assert ds[0]["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0, 1.5, 2.5, 3.5]
def test_type_promotion_int32_to_double(make_tsfile):
int32_path = make_tsfile("int", _write_numeric_field, TSDataType.INT32, T_INT32, lambda i: 10 + i)
double_path = make_tsfile("double", _write_single_device)
ds = load_dataset("tsfile", data_files=[int32_path, double_path])["train"]
assert len(ds) == 1
# INT32 + DOUBLE → DOUBLE (two-step widening).
assert ds.features["temperature"].feature.dtype == "float64"
assert ds[0]["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0, 10.0, 11.0, 12.0]
# ---------------------------------------------------------------------------
# All-types
# ---------------------------------------------------------------------------
def test_load_all_supported_types(all_types_tsfile_path):
ds = load_dataset("tsfile", data_files=all_types_tsfile_path)["train"]
assert len(ds) == 1
assert ds.column_names == [
"tag",
"time",
"col_boolean",
"col_int32",
"col_int64",
"col_float",
"col_double",
"col_text",
"col_string",
"col_timestamp",
"col_date",
"col_blob",
]
row = ds[0]
assert row["tag"] == "d1"
assert row["col_boolean"] == [True, False, True]
assert row["col_int32"] == [100, 101, 102]
assert row["col_int64"] == [1_000_000, 1_000_001, 1_000_002]
assert row["col_float"] == [1.5, 2.5, 3.5]
assert row["col_double"] == [100.5, 101.5, 102.5]
assert row["col_text"] == ["text_0", "text_1", "text_2"]
assert row["col_string"] == ["str_0", "str_1", "str_2"]
assert row["col_timestamp"][0] == datetime(2020, 9, 13, 12, 26, 40)
assert row["col_date"][0] == date(2024, 1, 1)
assert row["col_date"][2] == date(2024, 1, 3)
assert row["col_blob"][0] == b"blob0"
assert row["col_blob"][2] == b"blob2"
# ---------------------------------------------------------------------------
# Multi-table file: explicit `table_name` selection
# ---------------------------------------------------------------------------
def test_default_table_is_first(two_tables_tsfile_path):
ds = load_dataset("tsfile", data_files=two_tables_tsfile_path)["train"]
# `table_a` registered first → default pick.
assert "a" in ds.column_names
assert "b" not in ds.column_names
def test_explicit_table_name(two_tables_tsfile_path):
ds = load_dataset("tsfile", data_files=two_tables_tsfile_path, table_name="table_b")["train"]
assert "b" in ds.column_names
assert "a" not in ds.column_names
# ---------------------------------------------------------------------------
# Streaming (IterableDataset)
# ---------------------------------------------------------------------------
def test_streaming_yields_same_rows(multi_device_tsfile_path):
ds = load_dataset("tsfile", data_files=multi_device_tsfile_path, streaming=True)["train"]
assert isinstance(ds, IterableDataset)
rows = list(ds)
assert len(rows) == 3
assert sorted(r["device"] for r in rows) == ["d1", "d2", "d3"]
for r in rows:
assert r["temperature"] == [10.0, 11.0, 12.0]
# ---------------------------------------------------------------------------
# Timezone
# ---------------------------------------------------------------------------
def test_load_with_timezone(make_tsfile):
"""`timestamp_tz="UTC"` round-trips: list values come back tz-aware."""
path = make_tsfile("tz", _write_single_device)
ds = load_dataset("tsfile", data_files=path, timestamp_tz="UTC")["train"]
ts = ds[0]["time"][0]
assert ts.tzinfo is not None
# Same wall-clock as the naive case, attached to UTC.
assert ts == datetime(2023, 11, 14, 22, 13, 20, tzinfo=timezone.utc)
# ---------------------------------------------------------------------------
# Large-batch / multi-chunk concat
# ---------------------------------------------------------------------------
def test_large_device_with_small_batch_size(make_tsfile):
"""Force multiple Arrow batches per device → exercise the concat path."""
path = make_tsfile("big", _write_large_device, n_points=200)
ds = load_dataset("tsfile", data_files=path, input_batch_size=64)["train"]
assert len(ds) == 1
row = ds[0]
assert len(row["time"]) == 200
assert row["v"] == list(range(200))
# ---------------------------------------------------------------------------
# Duplicate-timestamp detection (cross-file)
# ---------------------------------------------------------------------------
def test_duplicate_timestamp_across_files_raises(make_tsfile):
"""Same device, same ts in two files → `_finalize_device` must raise."""
cols = [
("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
("device", TSDataType.STRING, ColumnCategory.TAG),
("v", TSDataType.DOUBLE, ColumnCategory.FIELD),
]
rows = [{"time": 5_000, "device": "d1", "v": 1.0}]
a = make_tsfile("dupA", lambda p: _write_tsfile(p, [("mytable", cols, [rows])]))
b = make_tsfile("dupB", lambda p: _write_tsfile(p, [("mytable", cols, [rows])]))
with pytest.raises(Exception) as excinfo:
load_dataset("tsfile", data_files=[a, b])
# The ValueError is wrapped by `_prepare_split_single` into a
# DatasetGenerationError; check the cause chain for the original message.
chain = [excinfo.value, *(_iter_causes(excinfo.value))]
assert any("Duplicate timestamp" in str(e) for e in chain)
def _iter_causes(exc: BaseException):
while exc.__cause__ is not None:
exc = exc.__cause__
yield exc
# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------
def test_on_bad_files_skip(tmp_path, tsfile_path):
bad = tmp_path / "broken.tsfile"
bad.write_bytes(b"not a real tsfile")
ds = load_dataset(
"tsfile",
data_files=[tsfile_path, str(bad)],
on_bad_files="skip",
)["train"]
assert len(ds) == 1
assert len(ds[0]["time"]) == 5
def test_on_bad_files_warn(tmp_path, tsfile_path, caplog):
bad = tmp_path / "broken.tsfile"
bad.write_bytes(b"not a real tsfile")
with caplog.at_level(logging.WARNING, logger="datasets.packaged_modules.tsfile.tsfile"):
ds = load_dataset(
"tsfile",
data_files=[tsfile_path, str(bad)],
on_bad_files="warn",
)["train"]
assert len(ds) == 1
assert any("Skipping bad file" in rec.message for rec in caplog.records)
def test_on_bad_files_default_raises(tmp_path, tsfile_path):
bad = tmp_path / "broken.tsfile"
bad.write_bytes(b"not a real tsfile")
with pytest.raises(Exception) as excinfo:
load_dataset("tsfile", data_files=[tsfile_path, str(bad)])
chain = [excinfo.value, *(_iter_causes(excinfo.value))]
assert any("not a valid TsFile" in str(e) for e in chain)