Coverage for src/signalk_cli/_arrow.py: 92%
71 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-06 00:17 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-06 00:17 +0000
1"""A small Arrow table built with nanoarrow, handed to dataframe libraries via the PyCapsule interface."""
3import json
4from collections.abc import Mapping, Sequence
5from datetime import datetime
6from typing import Any
8import nanoarrow as na
10_TIMESTAMP = na.timestamp("us", timezone="UTC")
12FEATHER_EXTENSIONS = {".feather", ".arrow", ".fea"}
15def _is_number(v: object) -> bool:
16 return isinstance(v, (int, float)) and not isinstance(v, bool)
19def as_text(v: object) -> str | None:
20 if v is None or isinstance(v, str):
21 return v
22 if isinstance(v, (dict, list)):
23 return json.dumps(v)
24 return str(v)
27def _epoch_us(ts: str | datetime | None) -> int | None:
28 if ts is None:
29 return None
30 dt = datetime.fromisoformat(ts) if isinstance(ts, str) else ts
31 return round(dt.timestamp() * 1_000_000)
34def infer_column(values: Sequence[Any]) -> tuple[list[str | float | bool | None], Any]:
35 """Pick one Arrow type for a column of JSON values: float64, bool, or string.
37 Numbers (and nulls) become float64, booleans stay bool, and anything else
38 — strings, objects, arrays, or a mix — becomes a string column, with
39 objects and arrays JSON-encoded.
40 """
41 present = [v for v in values if v is not None]
42 if all(_is_number(v) for v in present):
43 return [None if v is None else float(v) for v in values], na.float64()
44 if all(isinstance(v, bool) for v in present): 44 ↛ 45line 44 didn't jump to line 45 because the condition on line 44 was never true
45 return list(values), na.bool_()
46 return [as_text(v) for v in values], na.string()
49class ArrowTable:
50 """A table of query results, ready to load into any Arrow-aware dataframe library.
52 It implements the [Arrow PyCapsule interface](https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html),
53 so it can be passed straight to polars, pandas, pyarrow, DuckDB and others
54 without signalk-cli depending on any of them:
56 Examples:
57 ```python
58 import polars as pl
59 df = pl.DataFrame(table)
60 import pandas as pd
61 df = pd.DataFrame.from_arrow(table) # pandas >= 3.0
62 import pyarrow as pa
63 t = pa.table(table)
64 ```
65 """
67 def __init__(self, columns: dict[str, tuple[list, Any]]) -> None:
68 self._columns = {name: values for name, (values, _) in columns.items()}
69 self._types = {name: t for name, (_, t) in columns.items()}
70 lengths = {len(v) for v in self._columns.values()}
71 if len(lengths) > 1: 71 ↛ 72line 71 didn't jump to line 72 because the condition on line 71 was never true
72 raise ValueError(f"Columns have different lengths: {sorted(lengths)}")
73 self._num_rows = lengths.pop() if lengths else 0
75 @classmethod
76 def from_rows(
77 cls,
78 timestamps: Sequence[str | datetime | None],
79 columns: Mapping[str, Sequence[Any]],
80 *,
81 text_columns: Sequence[str] = (),
82 timestamp_name: str = "timestamp",
83 ) -> "ArrowTable":
84 """Build a table with a UTC timestamp column followed by the given columns.
86 Column types are inferred from the values (see `infer_column`),
87 except `text_columns`, which are always strings so an empty result
88 keeps the same schema.
89 """
90 built: dict[str, tuple[list, Any]] = {
91 timestamp_name: ([_epoch_us(t) for t in timestamps], _TIMESTAMP)
92 }
93 for name, values in columns.items():
94 if name in text_columns:
95 built[name] = ([as_text(v) for v in values], na.string())
96 else:
97 built[name] = infer_column(values)
98 return cls(built)
100 @property
101 def num_rows(self) -> int:
102 return self._num_rows
104 @property
105 def column_names(self) -> list[str]:
106 return list(self._columns)
108 def __len__(self) -> int:
109 return self._num_rows
111 def __repr__(self) -> str:
112 cols = ", ".join(f"{n}: {self._types[n].type.name}" for n in self._columns)
113 return f"ArrowTable({self._num_rows} rows; {cols})"
115 def to_pydict(self) -> dict[str, list]:
116 """Return the columns as plain Python lists (timestamps as epoch microseconds)."""
117 return {name: list(values) for name, values in self._columns.items()}
119 def _batch(self):
120 children = [
121 na.c_array(values, self._types[name])
122 for name, values in self._columns.items()
123 ]
124 schema = na.struct({name: self._types[name] for name in self._columns})
125 return na.c_array_from_buffers(
126 schema, self._num_rows, [None], children=children
127 )
129 def __arrow_c_schema__(self):
130 return na.c_schema(
131 na.struct({name: self._types[name] for name in self._columns})
132 ).__arrow_c_schema__()
134 def __arrow_c_stream__(self, requested_schema=None):
135 return na.ArrayStream(self._batch()).__arrow_c_stream__(requested_schema)
138def write_feather(table: ArrowTable, output: str) -> None:
139 """Write a table to a Feather (Arrow IPC file) — needs pyarrow."""
140 try:
141 import pyarrow as pa
142 from pyarrow import feather
143 except ImportError:
144 raise ImportError(
145 "pyarrow is required for Feather output: pip install 'signalk-cli[feather]'"
146 ) from None
147 feather.write_feather(pa.table(table), output)