Coverage for src/signalk_cli/history/cli.py: 99%

252 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-06 00:17 +0000

1"""Click CLI for the SignalK v2 History API.""" 

2 

3import contextlib 

4import csv 

5import json 

6import re 

7import sys 

8from collections.abc import Iterator 

9from datetime import UTC, datetime 

10from pathlib import Path 

11from urllib.parse import urlparse 

12 

13import click 

14import niquests 

15 

16from .._arrow import FEATHER_EXTENSIONS, write_feather 

17from .._cli import bare_option, host_option, resolve_host, stderr_ctx 

18from ..errors import SignalKError, api_error 

19from ._results import CARDINALITY_COLUMNS 

20from ._time import TimeRange 

21from .api import AGGREGATION_METHODS, HistoryClient, HistoryResult 

22from .output import ( 

23 cardinality_text, 

24 write_csv, 

25 write_csv_wide, 

26 write_json, 

27 write_json_wide, 

28) 

29 

30_AUTO_OUTPUT = "__auto_output__" 

31 

32# --------------------------------------------------------------------------- 

33# Shared option decorators 

34# --------------------------------------------------------------------------- 

35 

36 

37def _list_fmt_callback(ctx, param, value): 

38 if value is None: 38 ↛ 39line 38 didn't jump to line 39 because the condition on line 38 was never true

39 return value 

40 v = value.lower() 

41 if v in ("csv", "json", "raw"): 

42 return v 

43 if v == "feather": 

44 raise click.BadParameter( 

45 "feather output is only available on the `query` command " 

46 "(requires pip install 'signalk-cli[feather]')" 

47 ) 

48 raise click.BadParameter(f"'{value}' is not one of 'csv', 'json', 'raw'") 

49 

50 

51_host_option = host_option 

52_resolve_host = resolve_host 

53 

54 

55def _provider_options(f): 

56 f = click.option("--no-cache", is_flag=True, help="Ignore cached default provider")( 

57 f 

58 ) 

59 f = click.option( 

60 "--provider", 

61 help="History provider plugin id (default fetched and cached automatically)", 

62 )(f) 

63 return f 

64 

65 

66def _time_options(f): 

67 f = click.option( 

68 "--duration", 

69 metavar="DURATION", 

70 help="Duration: integer seconds or ISO 8601 (e.g. PT15M, 3600)", 

71 )(f) 

72 f = click.option("--to", metavar="DATETIME", help="End of range (ISO 8601)")(f) 

73 f = click.option( 

74 "--from", "from_", metavar="DATETIME", help="Start of range (ISO 8601)" 

75 )(f) 

76 return f 

77 

78 

79_bare_option = bare_option 

80_stderr_ctx = stderr_ctx 

81 

82 

83def _client( 

84 host, provider=None, no_cache=False, context="vessels.self" 

85) -> HistoryClient: 

86 return HistoryClient( 

87 resolve_host(host, no_cache), 

88 provider=provider, 

89 context=context, 

90 cache=not no_cache, 

91 ) 

92 

93 

94@contextlib.contextmanager 

95def _exit_on_error(doing: str) -> Iterator[None]: 

96 """Report a failed request as 'Error <doing>: <message>' and exit 1.""" 

97 try: 

98 yield 

99 except SignalKError as e: 

100 click.echo(f"Error {doing}: {e}", err=True) 

101 sys.exit(1) 

102 except niquests.RequestException as e: # failures mid-way through a streamed body 

103 click.echo(f"Error {doing}: {api_error(e)}", err=True) 

104 sys.exit(1) 

105 

106 

107def _echo_time(time_params: dict, width: int) -> None: 

108 for label, key, missing in ( 

109 ("From:", "from", "(server default)"), 

110 ("To:", "to", "(server default)"), 

111 ("Duration:", "duration", "(not specified)"), 

112 ): 

113 click.echo(f"{label:<{width}}{time_params.get(key, missing)}", err=True) 

114 

115 

116def _summary(table_paths, row_count: int) -> str: 

117 unique = sorted(set(table_paths)) 

118 return f"{row_count} rows, {len(unique)} unique path(s): {', '.join(unique)}" 

119 

120 

121# --------------------------------------------------------------------------- 

122# CLI group 

123# --------------------------------------------------------------------------- 

124 

125 

126@click.group(context_settings={"help_option_names": ["-h", "--help"]}) 

127def cli(): 

128 """SignalK v2 history CLI.""" 

129 

130 

131# --------------------------------------------------------------------------- 

132# query 

133# --------------------------------------------------------------------------- 

134 

135 

136@cli.command() 

137@click.argument("paths", nargs=-1, required=True, metavar="PATH...") 

138@_host_option 

139@_time_options 

140@click.option( 

141 "--resolution", 

142 metavar="RESOLUTION", 

143 help="Sample window: integer seconds or time expression (1s, 1m, 1h, 1d)", 

144) 

145@click.option( 

146 "--context", "-c", default="vessels.self", show_default=True, help="SignalK context" 

147) 

148@_provider_options 

149@click.option( 

150 "--aggregation", 

151 "--agg", 

152 "aggregation", 

153 type=click.Choice(AGGREGATION_METHODS, case_sensitive=False), 

154 default=None, 

155 help=( 

156 "Aggregation method applied to all paths. " 

157 "Omit for wide mode (min/max/average columns). " 

158 "Paths may also carry an inline ':method[:param]' suffix." 

159 ), 

160) 

161@click.option( 

162 "--samples", 

163 type=int, 

164 default=None, 

165 metavar="N", 

166 help="Sample count for --aggregation sma", 

167) 

168@click.option( 

169 "--alpha", 

170 type=float, 

171 default=None, 

172 metavar="FLOAT", 

173 help="Alpha value (0-1) for --aggregation ema", 

174) 

175@click.option( 

176 "--format", 

177 "fmt", 

178 default=None, 

179 type=click.Choice(["csv", "feather", "json", "raw"], case_sensitive=False), 

180 help="Output format (default: inferred from --output extension, else csv)", 

181) 

182@click.option("--no-header", is_flag=True, help="Suppress header row (CSV only)") 

183@click.option( 

184 "--output", 

185 "-o", 

186 is_flag=False, 

187 flag_value=_AUTO_OUTPUT, 

188 default=None, 

189 metavar="FILE", 

190 help="Write to FILE. Omit for stdout (default). Give without a filename to auto-name the file.", 

191) 

192@click.option( 

193 "--pretty", 

194 is_flag=True, 

195 help="Pretty-print JSON output (json/raw formats). Buffers the full response.", 

196) 

197@_bare_option 

198def query( 

199 paths, 

200 host, 

201 from_, 

202 to, 

203 duration, 

204 resolution, 

205 context, 

206 provider, 

207 no_cache, 

208 aggregation, 

209 samples, 

210 alpha, 

211 fmt, 

212 no_header, 

213 output, 

214 pretty, 

215 bare, 

216): 

217 """Query history and write results as CSV, JSON, or Feather. 

218 

219 Outputs to stdout by default. Use --output to write to a file. 

220 

221 PATH arguments may be literal SignalK paths, Python regex/glob patterns, 

222 or inline path specs with aggregation (e.g. navigation.speedOverGround:sma:5). 

223 

224 Without --aggregation and without inline specs, the default is wide mode: 

225 min/max/average are fetched per path and written as separate columns. 

226 

227 \b 

228 Examples: 

229 signalk_cli.history query --host 10.36.10.21 --duration PT1H navigation.speedOverGround 

230 signalk_cli.history query --host 10.36.10.21 --duration PT1H --agg sma --samples 5 '*' 

231 signalk_cli.history query --host 10.36.10.21 --duration PT1H navigation.speedOverGround:ema:0.2 

232 signalk_cli.history query --host 10.36.10.21 --from 2026-05-26T00:00:00Z --to 2026-05-27T00:00:00Z '*' 

233 """ 

234 with _stderr_ctx(bare): 

235 client = _client(host, provider, no_cache, context) 

236 time = TimeRange(from_, to, duration).resolved() 

237 time_params = time.params() 

238 

239 auto_name = output == _AUTO_OUTPUT 

240 

241 # Infer format from explicit output filename extension 

242 if fmt is None: 

243 if output and output not in (_AUTO_OUTPUT, "-"): 

244 suffix = Path(output).suffix.lower() 

245 if suffix in FEATHER_EXTENSIONS: 

246 fmt = "feather" 

247 elif suffix == ".json": 

248 fmt = "json" 

249 else: 

250 fmt = "csv" 

251 else: 

252 fmt = "csv" 

253 

254 # Generate auto-named file path now that format is known 

255 if auto_name: 

256 server_name = urlparse(client.host).hostname or re.sub( 

257 r"[^\w.-]", "_", client.host 

258 ) 

259 ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") 

260 ext = ( 

261 ".feather" 

262 if fmt == "feather" 

263 else ".json" 

264 if fmt in ("json", "raw") 

265 else ".csv" 

266 ) 

267 output = f"signalk-history-{server_name}-{ts}{ext}" 

268 

269 write_to_stdout = output is None or output == "-" 

270 write_to_file = not write_to_stdout 

271 

272 if fmt == "feather" and write_to_stdout: 

273 raise click.UsageError( 

274 "feather cannot be written to stdout (binary format); " 

275 "use --output FILE or --output to auto-name" 

276 ) 

277 

278 click.echo(f"Server: {client.host}", err=True) 

279 click.echo(f"Provider: {client.provider or '(none)'}", err=True) 

280 click.echo(f"Context: {context}", err=True) 

281 _echo_time(time_params, 13) 

282 click.echo(f"Resolution: {resolution or '(server default)'}", err=True) 

283 click.echo(f"Format: {fmt}", err=True) 

284 

285 with _exit_on_error("resolving paths"): 

286 resolved = client.expand_paths(list(paths), time) 

287 

288 if not resolved: 

289 click.echo("No paths matched — nothing to query.", err=True) 

290 sys.exit(1) 

291 

292 params, wide_mode = client.value_params( 

293 resolved, 

294 time, 

295 aggregation=aggregation, 

296 samples=samples, 

297 alpha=alpha, 

298 resolution=resolution, 

299 expand=False, 

300 ) 

301 agg_label = aggregation or ("wide (min/max/average)" if wide_mode else "inline") 

302 click.echo(f"Aggregation: {agg_label}", err=True) 

303 

304 # raw + stdout + no pretty: stream response bytes directly 

305 if fmt == "raw" and write_to_stdout and not pretty: 

306 with ( 

307 _exit_on_error("fetching history"), 

308 client.fetch("values", params, stream=True) as resp, 

309 ): 

310 for chunk in resp.iter_content(chunk_size=65536, decode_unicode=True): 

311 sys.stdout.write(chunk) 

312 sys.stdout.write("\n") 

313 return 

314 

315 with _exit_on_error("fetching history"): 

316 resp = client.fetch("values", params) 

317 

318 indent = 2 if pretty else None 

319 

320 if fmt == "feather": 

321 table = HistoryResult(resp.json(), wide=wide_mode).to_arrow() 

322 write_feather(table, output) 

323 click.echo(f"Wrote {output}", err=True) 

324 click.echo(_summary(table.to_pydict()["path"], table.num_rows), err=True) 

325 return 

326 

327 if fmt == "raw": 

328 text = ( 

329 json.dumps(resp.json(), indent=indent) if pretty else (resp.text or "") 

330 ) 

331 if write_to_file: 

332 Path(output).write_text(text) 

333 click.echo(f"Wrote {output}", err=True) 

334 else: 

335 sys.stdout.write(text + "\n") 

336 return 

337 

338 result = resp.json() 

339 with ( 

340 open(output, "w", newline="") 

341 if write_to_file 

342 else contextlib.nullcontext(sys.stdout) 

343 ) as sink: 

344 if fmt == "json": 

345 writer = write_json_wide if wide_mode else write_json 

346 row_count, unique_paths = writer(result, sink, indent=indent) 

347 if not write_to_file: 

348 sink.write("\n") 

349 else: 

350 writer = write_csv_wide if wide_mode else write_csv 

351 row_count, unique_paths = writer(result, sink, no_header) 

352 if write_to_file: 

353 click.echo(f"Wrote {output}", err=True) 

354 click.echo(_summary(unique_paths, row_count), err=True) 

355 

356 

357# --------------------------------------------------------------------------- 

358# cardinality 

359# --------------------------------------------------------------------------- 

360 

361 

362@cli.command() 

363@click.argument("paths", nargs=-1, required=False, metavar="PATH...") 

364@_host_option 

365@_time_options 

366@click.option( 

367 "--resolution", 

368 metavar="RESOLUTION", 

369 help="Sample window: integer seconds or time expression (1s, 1m, 1h, 1d)", 

370) 

371@click.option( 

372 "--context", "-c", default="vessels.self", show_default=True, help="SignalK context" 

373) 

374@_provider_options 

375@click.option( 

376 "--format", 

377 "fmt", 

378 metavar="[csv|json]", 

379 default="csv", 

380 callback=_list_fmt_callback, 

381 help="Output format: csv or json", 

382) 

383@click.option("--no-header", is_flag=True, help="Suppress header row (CSV only)") 

384@_bare_option 

385def cardinality( 

386 paths, 

387 host, 

388 from_, 

389 to, 

390 duration, 

391 resolution, 

392 context, 

393 provider, 

394 no_cache, 

395 fmt, 

396 no_header, 

397 bare, 

398): 

399 """Compute per-path value statistics for the given time range. 

400 

401 Outputs a table of: path, distinct_values, min, max, average, 

402 distinct_values_2_decimal_places, nulls. 

403 

404 For non-scalar values (e.g. navigation.position) min/max/average and 

405 distinct_values_2_decimal_places are left blank. 

406 

407 \b 

408 Examples: 

409 signalk_cli.history cardinality --host 10.36.10.21 --duration PT1H navigation.speedOverGround 

410 signalk_cli.history cardinality --host 10.36.10.21 --duration PT1H '*' 

411 """ 

412 with _stderr_ctx(bare): 

413 client = _client(host, provider, no_cache, context) 

414 time = TimeRange(from_, to, duration).resolved() 

415 

416 click.echo(f"Server: {client.host}", err=True) 

417 click.echo(f"Provider: {client.provider or '(none)'}", err=True) 

418 click.echo(f"Context: {context}", err=True) 

419 _echo_time(time.params(), 13) 

420 click.echo(f"Resolution: {resolution or '(server default)'}", err=True) 

421 

422 with _exit_on_error("resolving paths"): 

423 resolved = client.expand_paths(list(paths) or ["*"], time) 

424 

425 if not resolved: 

426 click.echo("No paths matched — nothing to query.", err=True) 

427 sys.exit(1) 

428 

429 with _exit_on_error("fetching history"): 

430 stat_rows = [ 

431 cardinality_text(r) 

432 for r in client.cardinality_rows(resolved, time, resolution=resolution) 

433 ] 

434 

435 if fmt == "json": 

436 click.echo(json.dumps(stat_rows, indent=2)) 

437 else: 

438 writer = csv.writer(sys.stdout) 

439 if not no_header: 

440 writer.writerow(CARDINALITY_COLUMNS) 

441 for row in stat_rows: 

442 writer.writerow([row[col] for col in CARDINALITY_COLUMNS]) 

443 

444 click.echo(f"{len(stat_rows)} path(s)", err=True) 

445 

446 

447# --------------------------------------------------------------------------- 

448# list-paths 

449# --------------------------------------------------------------------------- 

450 

451 

452@cli.command("list-paths") 

453@_host_option 

454@_time_options 

455@_provider_options 

456@click.option( 

457 "--context", "-c", default="vessels.self", show_default=True, help="SignalK context" 

458) 

459@click.option( 

460 "--format", 

461 "fmt", 

462 metavar="[csv|json|raw]", 

463 default="csv", 

464 callback=_list_fmt_callback, 

465 help="Output format: csv (one item per line), json (re-serialized), or raw (exact API response body). Feather is only available on `query` (requires signalk-cli[feather]).", 

466) 

467@_bare_option 

468def list_paths(host, from_, to, duration, provider, no_cache, context, fmt, bare): 

469 """List paths that have data for the given time range.""" 

470 with _stderr_ctx(bare): 

471 client = _client(host, provider, no_cache, context) 

472 time = TimeRange(from_, to, duration).resolved() 

473 

474 click.echo(f"Server: {client.host}", err=True) 

475 click.echo(f"Provider: {client.provider or '(none)'}", err=True) 

476 _echo_time(time.params(), 10) 

477 

478 with _exit_on_error("fetching paths"): 

479 if fmt == "raw": 

480 click.echo(client.fetch("paths", client.request_params(time)).text) 

481 return 

482 paths = client.paths(time) 

483 if fmt == "json": 

484 click.echo(json.dumps([{"path": p} for p in paths])) 

485 else: 

486 click.echo("path") 

487 for path in paths: 

488 click.echo(path) 

489 click.echo(f"{len(paths)} path(s)", err=True) 

490 

491 

492# --------------------------------------------------------------------------- 

493# list-providers 

494# --------------------------------------------------------------------------- 

495 

496 

497@cli.command("list-providers") 

498@_host_option 

499@click.option( 

500 "--format", 

501 "fmt", 

502 metavar="[csv|json|raw]", 

503 default="csv", 

504 callback=_list_fmt_callback, 

505 help="Output format: csv (one item per line), json (re-serialized), or raw (exact API response body). Feather is only available on `query` (requires signalk-cli[feather]).", 

506) 

507@_bare_option 

508def list_providers(host, fmt, bare): 

509 """List registered history providers.""" 

510 with _stderr_ctx(bare): 

511 client = _client(host) 

512 click.echo(f"Server: {client.host}", err=True) 

513 

514 with _exit_on_error("fetching providers"): 

515 if fmt == "raw": 

516 click.echo(client.fetch("_providers").text) 

517 return 

518 providers = client.providers() 

519 

520 if fmt == "json": 

521 rows = [ 

522 {"provider": pid, **info} for pid, info in sorted(providers.items()) 

523 ] 

524 click.echo(json.dumps(rows)) 

525 else: 

526 writer = csv.writer(sys.stdout) 

527 writer.writerow(["provider", "isDefault"]) 

528 for pid, info in sorted(providers.items()): 

529 writer.writerow([pid, info.get("isDefault", False)]) 

530 click.echo(f"{len(providers)} provider(s)", err=True) 

531 

532 

533# --------------------------------------------------------------------------- 

534# list-contexts 

535# --------------------------------------------------------------------------- 

536 

537 

538@cli.command("list-contexts") 

539@_host_option 

540@_time_options 

541@_provider_options 

542@click.option( 

543 "--format", 

544 "fmt", 

545 metavar="[csv|json|raw]", 

546 default="csv", 

547 callback=_list_fmt_callback, 

548 help="Output format: csv (one item per line), json (re-serialized), or raw (exact API response body). Feather is only available on `query` (requires signalk-cli[feather]).", 

549) 

550@_bare_option 

551def list_contexts(host, from_, to, duration, provider, no_cache, fmt, bare): 

552 """List contexts that have historical data for the given time range.""" 

553 with _stderr_ctx(bare): 

554 client = _client(host, provider, no_cache) 

555 time = TimeRange(from_, to, duration).resolved() 

556 

557 click.echo(f"Server: {client.host}", err=True) 

558 click.echo(f"Provider: {client.provider or '(none)'}", err=True) 

559 _echo_time(time.params(), 10) 

560 

561 with _exit_on_error("fetching contexts"): 

562 if fmt == "raw": 

563 click.echo(client.fetch("contexts", client.request_params(time)).text) 

564 return 

565 contexts = client.contexts(time) 

566 

567 if fmt == "json": 

568 click.echo(json.dumps([{"context": c} for c in contexts])) 

569 else: 

570 click.echo("context") 

571 for ctx in contexts: 

572 click.echo(ctx) 

573 click.echo(f"{len(contexts)} context(s)", err=True)