Coverage for src/signalk_cli/history/output.py: 79%

236 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-14 12:47 +0000

1"""CSV and Feather output writers for SignalK data.""" 

2 

3import csv 

4import json 

5import re 

6import sys 

7from typing import IO, cast 

8 

9_POSITION_RE = re.compile(r"navigation.*\.position") 

10 

11FEATHER_EXTENSIONS = {".feather", ".arrow", ".fea"} 

12 

13 

14class _MultiWriter: 

15 """Fans writes out to multiple underlying streams.""" 

16 

17 def __init__(self, *fhs): 

18 self._fhs = fhs 

19 

20 def write(self, s): 

21 for fh in self._fhs: 

22 fh.write(s) 

23 

24 def flush(self): 

25 for fh in self._fhs: 

26 fh.flush() 

27 

28 

29# --------------------------------------------------------------------------- 

30# Narrow mode (single value column) 

31# --------------------------------------------------------------------------- 

32 

33 

34def extract_rows(result: dict) -> tuple[list, list, list, set[str]]: 

35 """Flatten an API result into parallel (timestamps, paths, values, unique_paths) lists.""" 

36 value_columns = result.get("values", []) 

37 data_rows = result.get("data", []) 

38 timestamps, paths, values = [], [], [] 

39 unique_paths: set[str] = set() 

40 

41 for row in data_rows: 

42 if not row: 42 ↛ 43line 42 didn't jump to line 43 because the condition on line 42 was never true

43 continue 

44 timestamp = row[0] 

45 for i, col in enumerate(value_columns): 

46 path_name = col.get("path", f"col_{i}") 

47 value = row[i + 1] if i + 1 < len(row) else None 

48 if value is None: 

49 continue 

50 if isinstance(value, (dict, list)): 

51 value = json.dumps(value) 

52 elif not isinstance(value, str): 52 ↛ 54line 52 didn't jump to line 54 because the condition on line 52 was always true

53 value = str(value) 

54 timestamps.append(timestamp) 

55 paths.append(path_name) 

56 values.append(value) 

57 unique_paths.add(path_name) 

58 

59 return timestamps, paths, values, unique_paths 

60 

61 

62def write_csv(result: dict, sink, no_header: bool) -> tuple[int, set[str]]: 

63 """Write result as CSV rows (timestamp, path, value). Returns (row_count, unique_paths).""" 

64 timestamps, paths, values, unique_paths = extract_rows(result) 

65 writer = csv.writer(sink) 

66 if not no_header: 

67 writer.writerow(["timestamp", "path", "value"]) 

68 for ts, path, val in zip(timestamps, paths, values): 

69 writer.writerow([ts, path, val]) 

70 return len(timestamps), unique_paths 

71 

72 

73def write_feather(result: dict, output: str) -> tuple[int, set[str]]: 

74 """Write result as Feather (timestamp, path, value). Returns (row_count, unique_paths).""" 

75 try: 

76 import pyarrow as pa 

77 from pyarrow import feather 

78 except ImportError: 

79 raise ImportError( 

80 "pyarrow is required for Feather output: pip install 'signalk-cli[feather]'" 

81 ) from None 

82 timestamps, paths, values, unique_paths = extract_rows(result) 

83 table = pa.table( 

84 { 

85 "timestamp": pa.array(timestamps, type=pa.string()), 

86 "path": pa.array(paths, type=pa.string()), 

87 "value": pa.array(values, type=pa.string()), 

88 } 

89 ) 

90 feather.write_feather(table, output) 

91 return len(timestamps), unique_paths 

92 

93 

94# --------------------------------------------------------------------------- 

95# Wide mode (min_value / max_value / avg_value columns) 

96# --------------------------------------------------------------------------- 

97 

98 

99def _cell(row: list, col_idx: int) -> str: 

100 """Get a string cell value from a data row by 0-based column index (row[0] is timestamp).""" 

101 i = col_idx + 1 

102 if i >= len(row): 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true

103 return "" 

104 v = row[i] 

105 if v is None: 

106 return "" 

107 if isinstance(v, (dict, list)): 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true

108 return json.dumps(v) 

109 return str(v) 

110 

111 

112def _peek_first_value(data_rows: list, col_idx: int): 

113 """First non-None value at col_idx+1 across data rows.""" 

114 for row in data_rows: 114 ↛ 119line 114 didn't jump to line 119 because the loop on line 114 didn't complete

115 if row: 115 ↛ 114line 115 didn't jump to line 114 because the condition on line 115 was always true

116 i = col_idx + 1 

117 if i < len(row) and row[i] is not None: 

118 return row[i] 

119 return None 

120 

121 

122def _array_col_names(path: str, length: int) -> list[str]: 

123 """Column names for an array-valued path.""" 

124 if length == 2 and _POSITION_RE.fullmatch(path): 

125 return ["longitude", "latitude"] 

126 return [f"value_{i}" for i in range(length)] 

127 

128 

129def extract_rows_wide(result: dict) -> tuple[list, list, dict[str, list], set[str]]: 

130 """Extract rows as (timestamps, paths, value_cols, unique_paths). 

131 

132 value_cols is an ordered dict of column_name -> list of string values. 

133 Scalar paths produce min_value / avg_value / max_value columns. 

134 Array paths produce value_0 / value_1 / ... (or latitude / longitude for 

135 navigation.*.position paths of length 2). Both may appear in one result; 

136 non-applicable cells are empty strings. 

137 """ 

138 value_columns = result.get("values", []) 

139 data_rows = result.get("data", []) 

140 

141 # Build {path: {method: col_idx}} preserving path order 

142 path_method_idx: dict[str, dict[str, int]] = {} 

143 for i, col in enumerate(value_columns): 

144 path = col.get("path", f"col_{i}") 

145 method = col.get("method", "") 

146 path_method_idx.setdefault(path, {})[method] = i 

147 

148 ordered_paths = list(path_method_idx) 

149 

150 # Determine per-path output columns by peeking at the first non-null value 

151 path_col_names: dict[str, list[str]] = {} 

152 path_is_array: dict[str, bool] = {} 

153 for path, methods in path_method_idx.items(): 

154 sample = None 

155 for col_idx in methods.values(): 155 ↛ 159line 155 didn't jump to line 159 because the loop on line 155 didn't complete

156 sample = _peek_first_value(data_rows, col_idx) 

157 if sample is not None: 157 ↛ 155line 157 didn't jump to line 155 because the condition on line 157 was always true

158 break 

159 if isinstance(sample, list): 

160 path_is_array[path] = True 

161 path_col_names[path] = _array_col_names(path, len(sample)) 

162 else: 

163 path_is_array[path] = False 

164 path_col_names[path] = ["min_value", "avg_value", "max_value"] 

165 

166 # Collect all unique column names in first-seen order 

167 all_col_names: list[str] = [] 

168 seen_cols: set[str] = set() 

169 for path in ordered_paths: 

170 for col in path_col_names[path]: 

171 if col not in seen_cols: 

172 all_col_names.append(col) 

173 seen_cols.add(col) 

174 

175 timestamps: list = [] 

176 paths_out: list = [] 

177 value_cols: dict[str, list] = {col: [] for col in all_col_names} 

178 unique_paths: set[str] = set() 

179 

180 for row in data_rows: 

181 if not row: 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true

182 continue 

183 ts = row[0] 

184 for path in ordered_paths: 

185 methods = path_method_idx[path] 

186 is_array = path_is_array[path] 

187 cols = path_col_names[path] 

188 

189 if is_array: 

190 arr_val = None 

191 for col_idx in methods.values(): 191 ↛ 196line 191 didn't jump to line 196 because the loop on line 191 didn't complete

192 i = col_idx + 1 

193 if i < len(row) and row[i] is not None: 193 ↛ 191line 193 didn't jump to line 191 because the condition on line 193 was always true

194 arr_val = row[i] 

195 break 

196 if arr_val is None: 196 ↛ 197line 196 didn't jump to line 197 because the condition on line 196 was never true

197 continue 

198 timestamps.append(ts) 

199 paths_out.append(path) 

200 unique_paths.add(path) 

201 row_vals: dict[str, str] = {c: "" for c in all_col_names} 

202 for j, col_name in enumerate(cols): 

203 if j < len(arr_val): 203 ↛ 202line 203 didn't jump to line 202 because the condition on line 203 was always true

204 row_vals[col_name] = str(arr_val[j]) 

205 for col_name in all_col_names: 

206 value_cols[col_name].append(row_vals[col_name]) 

207 else: 

208 mn = _cell(row, methods["min"]) if "min" in methods else "" 

209 av = _cell(row, methods["average"]) if "average" in methods else "" 

210 mx = _cell(row, methods["max"]) if "max" in methods else "" 

211 if not mn and not av and not mx: 

212 continue 

213 timestamps.append(ts) 

214 paths_out.append(path) 

215 unique_paths.add(path) 

216 row_vals = {c: "" for c in all_col_names} 

217 row_vals["min_value"] = mn 

218 row_vals["avg_value"] = av 

219 row_vals["max_value"] = mx 

220 for col_name in all_col_names: 

221 value_cols[col_name].append(row_vals[col_name]) 

222 

223 return timestamps, paths_out, value_cols, unique_paths 

224 

225 

226def write_csv_wide(result: dict, sink, no_header: bool) -> tuple[int, set[str]]: 

227 """Write result as CSV with dynamic value columns (scalar: min/avg/max; array: named elements).""" 

228 timestamps, paths, value_cols, unique_paths = extract_rows_wide(result) 

229 col_names = list(value_cols.keys()) 

230 writer = csv.writer(sink) 

231 if not no_header: 231 ↛ 233line 231 didn't jump to line 233 because the condition on line 231 was always true

232 writer.writerow(["timestamp", "path"] + col_names) 

233 for i, (ts, path) in enumerate(zip(timestamps, paths)): 

234 writer.writerow([ts, path] + [value_cols[col][i] for col in col_names]) 

235 return len(timestamps), unique_paths 

236 

237 

238def write_feather_wide(result: dict, output: str) -> tuple[int, set[str]]: 

239 """Write result as Feather with dynamic value columns.""" 

240 try: 

241 import pyarrow as pa 

242 from pyarrow import feather 

243 except ImportError: 

244 raise ImportError( 

245 "pyarrow is required for Feather output: pip install 'signalk-cli[feather]'" 

246 ) from None 

247 timestamps, paths, value_cols, unique_paths = extract_rows_wide(result) 

248 table = pa.table( 

249 { 

250 "timestamp": pa.array(timestamps, type=pa.string()), 

251 "path": pa.array(paths, type=pa.string()), 

252 **{ 

253 col: pa.array(vals, type=pa.string()) 

254 for col, vals in value_cols.items() 

255 }, 

256 } 

257 ) 

258 feather.write_feather(table, output) 

259 return len(timestamps), unique_paths 

260 

261 

262def write_json(result: dict, sink, indent: int | None = None) -> tuple[int, set[str]]: 

263 """Write result as JSON array of row objects (narrow mode).""" 

264 timestamps, paths, values, unique_paths = extract_rows(result) 

265 rows = [ 

266 {"timestamp": ts, "path": p, "value": v} 

267 for ts, p, v in zip(timestamps, paths, values) 

268 ] 

269 sink.write(json.dumps(rows, indent=indent)) 

270 return len(rows), unique_paths 

271 

272 

273def write_json_wide( 

274 result: dict, sink, indent: int | None = None 

275) -> tuple[int, set[str]]: 

276 """Write result as JSON array of row objects (wide mode).""" 

277 timestamps, paths, value_cols, unique_paths = extract_rows_wide(result) 

278 col_names = list(value_cols.keys()) 

279 rows = [ 

280 {"timestamp": ts, "path": p, **{col: value_cols[col][i] for col in col_names}} 

281 for i, (ts, p) in enumerate(zip(timestamps, paths)) 

282 ] 

283 sink.write(json.dumps(rows, indent=indent)) 

284 return len(rows), unique_paths 

285 

286 

287# --------------------------------------------------------------------------- 

288# Cardinality 

289# --------------------------------------------------------------------------- 

290 

291CARDINALITY_COLUMNS = [ 

292 "path", 

293 "distinct_values", 

294 "distinct_values_2_decimal_places", 

295 "nulls", 

296 "zeroes", 

297 "min", 

298 "max", 

299 "average", 

300] 

301 

302 

303def compute_cardinality(result: dict) -> list[dict]: 

304 """Compute per-path value statistics from a narrow-mode API result. 

305 

306 Each returned dict has keys matching CARDINALITY_COLUMNS. min, max, 

307 average, and distinct_values_2_decimal_places are empty strings for 

308 non-scalar (array/dict) paths. 

309 """ 

310 value_columns = result.get("values", []) 

311 data_rows = result.get("data", []) 

312 

313 ordered_paths: list[str] = [] 

314 path_col_idxs: dict[str, list[int]] = {} 

315 for i, col in enumerate(value_columns): 

316 path = col.get("path", f"col_{i}") 

317 if path not in path_col_idxs: 317 ↛ 320line 317 didn't jump to line 320 because the condition on line 317 was always true

318 ordered_paths.append(path) 

319 path_col_idxs[path] = [] 

320 path_col_idxs[path].append(i) 

321 

322 path_vals: dict[str, list] = {p: [] for p in ordered_paths} 

323 path_nulls: dict[str, int] = {p: 0 for p in ordered_paths} 

324 path_zeroes: dict[str, int] = {p: 0 for p in ordered_paths} 

325 

326 for row in data_rows: 

327 if not row: 327 ↛ 328line 327 didn't jump to line 328 because the condition on line 327 was never true

328 continue 

329 for path, col_idxs in path_col_idxs.items(): 

330 for col_idx in col_idxs: 

331 val = row[col_idx + 1] if col_idx + 1 < len(row) else None 

332 if val is None: 

333 path_nulls[path] += 1 

334 else: 

335 if ( 

336 isinstance(val, (int, float)) 

337 and not isinstance(val, bool) 

338 and val == 0 

339 ): 

340 path_zeroes[path] += 1 

341 path_vals[path].append(val) 

342 

343 rows = [] 

344 for path in ordered_paths: 

345 vals = path_vals[path] 

346 nulls = path_nulls[path] 

347 is_scalar = bool(vals) and all( 

348 isinstance(v, (int, float)) and not isinstance(v, bool) for v in vals 

349 ) 

350 

351 distinct = len( 

352 { 

353 json.dumps(v, sort_keys=True) if isinstance(v, (dict, list)) else str(v) 

354 for v in vals 

355 } 

356 ) 

357 

358 if not vals: 

359 mn = mx = avg = "" 

360 d2dp = "0" 

361 elif is_scalar: 

362 mn = str(min(vals)) 

363 mx = str(max(vals)) 

364 avg = str(sum(vals) / len(vals)) 

365 d2dp = str(len({round(float(v), 2) for v in vals})) 

366 else: 

367 mn = mx = avg = "" 

368 if vals and isinstance(vals[0], list): 368 ↛ 384line 368 didn't jump to line 384 because the condition on line 368 was always true

369 d2dp = str( 

370 len( 

371 { 

372 tuple( 

373 round(x, 2) 

374 if isinstance(x, (int, float)) 

375 and not isinstance(x, bool) 

376 else x 

377 for x in v 

378 ) 

379 for v in vals 

380 } 

381 ) 

382 ) 

383 else: 

384 d2dp = "" 

385 

386 rows.append( 

387 { 

388 "path": path, 

389 "distinct_values": str(distinct), 

390 "min": mn, 

391 "max": mx, 

392 "average": avg, 

393 "distinct_values_2_decimal_places": d2dp, 

394 "nulls": str(nulls), 

395 "zeroes": str(path_zeroes[path]), 

396 } 

397 ) 

398 

399 return rows 

400 

401 

402# --------------------------------------------------------------------------- 

403# Sink helper 

404# --------------------------------------------------------------------------- 

405 

406 

407def csv_sink( 

408 output: str, write_to_file: bool, write_to_stdout: bool 

409) -> tuple[IO[str] | None, _MultiWriter | IO[str]]: 

410 """Return (file_handle_or_None, sink) for CSV writing. Caller must close file_handle.""" 

411 file_fh: IO[str] | None = ( 

412 open(output, "w", newline="") if write_to_file else None # noqa: SIM115 

413 ) 

414 sink: _MultiWriter | IO[str] 

415 if write_to_file and write_to_stdout: 

416 sink = _MultiWriter(cast(IO[str], file_fh), sys.stdout) 

417 elif write_to_file: 

418 sink = cast(IO[str], file_fh) 

419 else: 

420 sink = sys.stdout 

421 return file_fh, sink