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

357 statements  

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

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

2 

3import csv 

4import json 

5import re 

6import sys 

7from datetime import UTC, datetime 

8from pathlib import Path 

9from urllib.parse import urlparse 

10 

11import click 

12import niquests 

13 

14from ..net import bare_option, host_option, resolve_host, stderr_ctx 

15from .history_api import ( 

16 HISTORY_BASE, 

17 api_error, 

18 apply_time_default, 

19 expand_paths, 

20 fetch_server_paths, 

21 normalise_duration, 

22 resolve_provider, 

23) 

24from .output import ( 

25 _POSITION_RE, 

26 CARDINALITY_COLUMNS, 

27 FEATHER_EXTENSIONS, 

28 compute_cardinality, 

29 write_csv, 

30 write_csv_wide, 

31 write_feather, 

32 write_feather_wide, 

33 write_json, 

34 write_json_wide, 

35) 

36 

37_AUTO_OUTPUT = "__auto_output__" 

38 

39AGGREGATION_METHODS = [ 

40 "average", 

41 "min", 

42 "max", 

43 "first", 

44 "last", 

45 "mid", 

46 "middle_index", 

47 "sma", 

48 "ema", 

49] 

50 

51# --------------------------------------------------------------------------- 

52# Shared option decorators 

53# --------------------------------------------------------------------------- 

54 

55 

56def _list_fmt_callback(ctx, param, value): 

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

58 return value 

59 v = value.lower() 

60 if v in ("csv", "json", "raw"): 60 ↛ 62line 60 didn't jump to line 62 because the condition on line 60 was always true

61 return v 

62 if v == "feather": 

63 raise click.BadParameter( 

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

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

66 ) 

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

68 

69 

70_host_option = host_option 

71_resolve_host = resolve_host 

72 

73 

74def _provider_options(f): 

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

76 f 

77 ) 

78 f = click.option( 

79 "--provider", 

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

81 )(f) 

82 return f 

83 

84 

85def _time_options(f): 

86 f = click.option( 

87 "--duration", 

88 metavar="DURATION", 

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

90 )(f) 

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

92 f = click.option( 

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

94 )(f) 

95 return f 

96 

97 

98_bare_option = bare_option 

99_stderr_ctx = stderr_ctx 

100 

101 

102def _build_time_params(from_: str | None, to: str | None, duration: str | None) -> dict: 

103 p: dict = {} 

104 if from_: 104 ↛ 105line 104 didn't jump to line 105 because the condition on line 104 was never true

105 p["from"] = from_ 

106 if to: 106 ↛ 107line 106 didn't jump to line 107 because the condition on line 106 was never true

107 p["to"] = to 

108 if duration: 108 ↛ 110line 108 didn't jump to line 110 because the condition on line 108 was always true

109 p["duration"] = duration 

110 return p 

111 

112 

113def _build_path_specs( 

114 paths: list[str], 

115 aggregation: str | None, 

116 samples: int | None, 

117 alpha: float | None, 

118) -> tuple[str, bool]: 

119 """Build the comma-separated paths query param with aggregation suffixes. 

120 

121 Returns (query_string, wide_mode). wide_mode is True when no aggregation 

122 is given and no path contains an inline ':method' suffix — in that case 

123 min/max/average are requested and the output uses wide columns. 

124 """ 

125 has_inline = any(":" in p for p in paths) 

126 

127 if aggregation: 

128 specs = [] 

129 for path in paths: 

130 if ":" in path: 

131 specs.append(path) # inline spec passes through unchanged 

132 else: 

133 spec = f"{path}:{aggregation}" 

134 if aggregation == "sma" and samples is not None: 

135 spec += f":{samples}" 

136 elif aggregation == "ema" and alpha is not None: 

137 spec += f":{alpha}" 

138 specs.append(spec) 

139 return ",".join(specs), False 

140 

141 if has_inline: 

142 return ",".join(paths), False 

143 

144 # Default: wide mode. Array-valued paths (e.g. navigation.position) don't 

145 # support min/average/max aggregation, so request a single passthrough method 

146 # instead; the output layer expands the array into named columns. 

147 specs = [] 

148 for p in paths: 

149 if _POSITION_RE.fullmatch(p): 149 ↛ 150line 149 didn't jump to line 150 because the condition on line 149 was never true

150 specs.append(f"{p}:mid") 

151 else: 

152 for m in ("min", "average", "max"): 

153 specs.append(f"{p}:{m}") 

154 return ",".join(specs), True 

155 

156 

157# --------------------------------------------------------------------------- 

158# CLI group 

159# --------------------------------------------------------------------------- 

160 

161 

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

163def cli(): 

164 """SignalK v2 history CLI.""" 

165 

166 

167# --------------------------------------------------------------------------- 

168# query 

169# --------------------------------------------------------------------------- 

170 

171 

172@cli.command() 

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

174@_host_option 

175@_time_options 

176@click.option( 

177 "--resolution", 

178 metavar="RESOLUTION", 

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

180) 

181@click.option( 

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

183) 

184@_provider_options 

185@click.option( 

186 "--aggregation", 

187 "--agg", 

188 "aggregation", 

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

190 default=None, 

191 help=( 

192 "Aggregation method applied to all paths. " 

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

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

195 ), 

196) 

197@click.option( 

198 "--samples", 

199 type=int, 

200 default=None, 

201 metavar="N", 

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

203) 

204@click.option( 

205 "--alpha", 

206 type=float, 

207 default=None, 

208 metavar="FLOAT", 

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

210) 

211@click.option( 

212 "--format", 

213 "fmt", 

214 default=None, 

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

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

217) 

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

219@click.option( 

220 "--output", 

221 "-o", 

222 is_flag=False, 

223 flag_value=_AUTO_OUTPUT, 

224 default=None, 

225 metavar="FILE", 

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

227) 

228@click.option( 

229 "--pretty", 

230 is_flag=True, 

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

232) 

233@_bare_option 

234def query( 

235 paths, 

236 host, 

237 from_, 

238 to, 

239 duration, 

240 resolution, 

241 context, 

242 provider, 

243 no_cache, 

244 aggregation, 

245 samples, 

246 alpha, 

247 fmt, 

248 no_header, 

249 output, 

250 pretty, 

251 bare, 

252): 

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

254 

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

256 

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

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

259 

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

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

262 

263 \b 

264 Examples: 

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

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

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

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

269 """ 

270 with _stderr_ctx(bare): 

271 host = _resolve_host(host, no_cache) 

272 base_url = host.rstrip("/") + HISTORY_BASE 

273 provider = resolve_provider(host, base_url, provider, no_cache) 

274 

275 # Normalise date-component durations (P1D etc.) to explicit from/to timestamps 

276 from_, to, duration = normalise_duration(duration, from_, to) 

277 time_params = apply_time_default(_build_time_params(from_, to, duration)) 

278 

279 # Determine output destination 

280 if output == _AUTO_OUTPUT: 280 ↛ 282line 280 didn't jump to line 282 because the condition on line 280 was never true

281 # Placeholder — filename generated after format is known 

282 auto_name = True 

283 else: 

284 auto_name = False 

285 

286 # Infer format from explicit output filename extension 

287 if fmt is None: 

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

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

290 if suffix in FEATHER_EXTENSIONS: 290 ↛ 291line 290 didn't jump to line 291 because the condition on line 290 was never true

291 fmt = "feather" 

292 elif suffix == ".json": 292 ↛ 293line 292 didn't jump to line 293 because the condition on line 292 was never true

293 fmt = "json" 

294 else: 

295 fmt = "csv" 

296 else: 

297 fmt = "csv" 

298 

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

300 if auto_name: 300 ↛ 301line 300 didn't jump to line 301 because the condition on line 300 was never true

301 server_name = urlparse(host).hostname or re.sub(r"[^\w.-]", "_", host) 

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

303 ext = ( 

304 ".feather" 

305 if fmt == "feather" 

306 else ".json" 

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

308 else ".csv" 

309 ) 

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

311 

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

313 write_to_file = not write_to_stdout 

314 

315 if fmt == "feather" and write_to_stdout: 

316 raise click.UsageError( 

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

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

319 ) 

320 

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

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

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

324 click.echo( 

325 f"From: {time_params.get('from', '(server default)')}", err=True 

326 ) 

327 click.echo( 

328 f"To: {time_params.get('to', '(server default)')}", err=True 

329 ) 

330 click.echo( 

331 f"Duration: {time_params.get('duration', '(not specified)')}", err=True 

332 ) 

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

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

335 

336 try: 

337 resolved = expand_paths(list(paths), base_url, time_params, provider) 

338 except niquests.RequestException as e: 

339 click.echo(f"Error resolving paths: {api_error(e)}", err=True) 

340 sys.exit(1) 

341 

342 if not resolved: 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true

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

344 sys.exit(1) 

345 

346 path_query, wide_mode = _build_path_specs(resolved, aggregation, samples, alpha) 

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

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

349 

350 params: dict = {**time_params, "paths": path_query, "context": context} 

351 if resolution: 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true

352 params["resolution"] = resolution 

353 if provider: 353 ↛ 356line 353 didn't jump to line 356 because the condition on line 353 was always true

354 params["provider"] = provider 

355 

356 url = f"{base_url}/values" 

357 

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

359 if fmt == "raw" and write_to_stdout and not pretty: 359 ↛ 360line 359 didn't jump to line 360 because the condition on line 359 was never true

360 try: 

361 with niquests.get(url, params=params, timeout=60, stream=True) as resp: 

362 resp.raise_for_status() 

363 for chunk in resp.iter_content( 

364 chunk_size=65536, decode_unicode=True 

365 ): 

366 sys.stdout.write(chunk) 

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

368 except niquests.RequestException as e: 

369 click.echo(f"Error fetching history: {api_error(e)}", err=True) 

370 sys.exit(1) 

371 return 

372 

373 try: 

374 resp = niquests.get(url, params=params, timeout=60) 

375 resp.raise_for_status() 

376 except niquests.RequestException as e: 

377 click.echo(f"Error fetching history: {api_error(e)}", err=True) 

378 sys.exit(1) 

379 

380 indent = 2 if pretty else None 

381 

382 def _open_sink(): 

383 if write_to_file: 

384 return open(output, "w", newline="") 

385 return None 

386 

387 if fmt == "feather": 387 ↛ 388line 387 didn't jump to line 388 because the condition on line 387 was never true

388 if wide_mode: 

389 row_count, unique_paths = write_feather_wide(resp.json(), output) 

390 else: 

391 row_count, unique_paths = write_feather(resp.json(), output) 

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

393 click.echo( 

394 f"{row_count} rows, {len(unique_paths)} unique path(s): {', '.join(sorted(unique_paths))}", 

395 err=True, 

396 ) 

397 

398 elif fmt == "raw": 398 ↛ 399line 398 didn't jump to line 399 because the condition on line 398 was never true

399 raw_text = json.dumps(resp.json(), indent=indent) if pretty else resp.text 

400 fh = _open_sink() 

401 try: 

402 (fh or sys.stdout).write(raw_text) 

403 if not write_to_file: 

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

405 finally: 

406 if fh: 

407 fh.close() 

408 if write_to_file: 

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

410 

411 elif fmt == "json": 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true

412 result = resp.json() 

413 fh = _open_sink() 

414 try: 

415 sink = fh or sys.stdout 

416 if wide_mode: 

417 row_count, unique_paths = write_json_wide( 

418 result, sink, indent=indent 

419 ) 

420 else: 

421 row_count, unique_paths = write_json(result, sink, indent=indent) 

422 if not write_to_file: 

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

424 finally: 

425 if fh: 

426 fh.close() 

427 if write_to_file: 

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

429 click.echo( 

430 f"{row_count} rows, {len(unique_paths)} unique path(s): {', '.join(sorted(unique_paths))}", 

431 err=True, 

432 ) 

433 

434 else: # csv 

435 result = resp.json() 

436 fh = _open_sink() 

437 try: 

438 sink = fh or sys.stdout 

439 if wide_mode: 

440 row_count, unique_paths = write_csv_wide(result, sink, no_header) 

441 else: 

442 row_count, unique_paths = write_csv(result, sink, no_header) 

443 finally: 

444 if fh: 

445 fh.close() 

446 if write_to_file: 

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

448 click.echo( 

449 f"{row_count} rows, {len(unique_paths)} unique path(s): {', '.join(sorted(unique_paths))}", 

450 err=True, 

451 ) 

452 

453 

454# --------------------------------------------------------------------------- 

455# cardinality 

456# --------------------------------------------------------------------------- 

457 

458 

459@cli.command() 

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

461@_host_option 

462@_time_options 

463@click.option( 

464 "--resolution", 

465 metavar="RESOLUTION", 

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

467) 

468@click.option( 

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

470) 

471@_provider_options 

472@click.option( 

473 "--format", 

474 "fmt", 

475 metavar="[csv|json]", 

476 default="csv", 

477 callback=_list_fmt_callback, 

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

479) 

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

481@_bare_option 

482def cardinality( 

483 paths, 

484 host, 

485 from_, 

486 to, 

487 duration, 

488 resolution, 

489 context, 

490 provider, 

491 no_cache, 

492 fmt, 

493 no_header, 

494 bare, 

495): 

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

497 

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

499 distinct_values_2_decimal_places, nulls. 

500 

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

502 distinct_values_2_decimal_places are left blank. 

503 

504 \b 

505 Examples: 

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

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

508 """ 

509 with _stderr_ctx(bare): 

510 host = _resolve_host(host, no_cache) 

511 base_url = host.rstrip("/") + HISTORY_BASE 

512 provider = resolve_provider(host, base_url, provider, no_cache) 

513 

514 from_, to, duration = normalise_duration(duration, from_, to) 

515 time_params = apply_time_default(_build_time_params(from_, to, duration)) 

516 

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

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

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

520 click.echo( 

521 f"From: {time_params.get('from', '(server default)')}", err=True 

522 ) 

523 click.echo( 

524 f"To: {time_params.get('to', '(server default)')}", err=True 

525 ) 

526 click.echo( 

527 f"Duration: {time_params.get('duration', '(not specified)')}", err=True 

528 ) 

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

530 

531 try: 

532 resolved = expand_paths( 

533 list(paths) or ["*"], base_url, time_params, provider 

534 ) 

535 except niquests.RequestException as e: 

536 click.echo(f"Error resolving paths: {api_error(e)}", err=True) 

537 sys.exit(1) 

538 

539 if not resolved: 

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

541 sys.exit(1) 

542 

543 params: dict = { 

544 **time_params, 

545 "paths": ",".join(resolved), 

546 "context": context, 

547 } 

548 if resolution: 

549 params["resolution"] = resolution 

550 if provider: 

551 params["provider"] = provider 

552 

553 try: 

554 resp = niquests.get(f"{base_url}/values", params=params, timeout=60) 

555 resp.raise_for_status() 

556 except niquests.RequestException as e: 

557 click.echo(f"Error fetching history: {api_error(e)}", err=True) 

558 sys.exit(1) 

559 

560 stat_rows = compute_cardinality(resp.json()) 

561 

562 if fmt == "json": 

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

564 else: 

565 writer = csv.writer(sys.stdout) 

566 if not no_header: 

567 writer.writerow(CARDINALITY_COLUMNS) 

568 for row in stat_rows: 

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

570 

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

572 

573 

574# --------------------------------------------------------------------------- 

575# list-paths 

576# --------------------------------------------------------------------------- 

577 

578 

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

580@_host_option 

581@_time_options 

582@_provider_options 

583@click.option( 

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

585) 

586@click.option( 

587 "--format", 

588 "fmt", 

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

590 default="csv", 

591 callback=_list_fmt_callback, 

592 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]).", 

593) 

594@_bare_option 

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

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

597 with _stderr_ctx(bare): 

598 host = _resolve_host(host, no_cache) 

599 base_url = host.rstrip("/") + HISTORY_BASE 

600 provider = resolve_provider(host, base_url, provider, no_cache) 

601 time_params = apply_time_default(_build_time_params(from_, to, duration)) 

602 

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

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

605 click.echo(f"From: {time_params.get('from', '(server default)')}", err=True) 

606 click.echo(f"To: {time_params.get('to', '(server default)')}", err=True) 

607 click.echo( 

608 f"Duration: {time_params.get('duration', '(not specified)')}", err=True 

609 ) 

610 

611 if fmt == "raw": 611 ↛ 612line 611 didn't jump to line 612 because the condition on line 611 was never true

612 params = {k: v for k, v in time_params.items() if v is not None} 

613 if provider: 

614 params["provider"] = provider 

615 try: 

616 resp = niquests.get(f"{base_url}/paths", params=params, timeout=30) 

617 resp.raise_for_status() 

618 except niquests.RequestException as e: 

619 click.echo(f"Error fetching paths: {api_error(e)}", err=True) 

620 sys.exit(1) 

621 click.echo(resp.text) 

622 else: 

623 try: 

624 paths = fetch_server_paths(base_url, time_params, provider) 

625 except niquests.RequestException as e: 

626 click.echo(f"Error fetching paths: {api_error(e)}", err=True) 

627 sys.exit(1) 

628 if fmt == "json": 628 ↛ 629line 628 didn't jump to line 629 because the condition on line 628 was never true

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

630 else: 

631 click.echo("path") 

632 for path in sorted(paths): 

633 click.echo(path) 

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

635 

636 

637# --------------------------------------------------------------------------- 

638# list-providers 

639# --------------------------------------------------------------------------- 

640 

641 

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

643@_host_option 

644@click.option( 

645 "--format", 

646 "fmt", 

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

648 default="csv", 

649 callback=_list_fmt_callback, 

650 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]).", 

651) 

652@_bare_option 

653def list_providers(host, fmt, bare): 

654 """List registered history providers.""" 

655 with _stderr_ctx(bare): 

656 host = _resolve_host(host) 

657 base_url = host.rstrip("/") + HISTORY_BASE 

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

659 

660 try: 

661 resp = niquests.get(f"{base_url}/_providers", timeout=10) 

662 resp.raise_for_status() 

663 except niquests.RequestException as e: 

664 click.echo(f"Error fetching providers: {api_error(e)}", err=True) 

665 sys.exit(1) 

666 

667 if fmt == "raw": 667 ↛ 668line 667 didn't jump to line 668 because the condition on line 667 was never true

668 click.echo(resp.text) 

669 else: 

670 providers: dict = resp.json() 

671 if fmt == "json": 671 ↛ 672line 671 didn't jump to line 672 because the condition on line 671 was never true

672 rows = [ 

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

674 ] 

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

676 else: 

677 writer = csv.writer(sys.stdout) 

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

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

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

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

682 

683 

684# --------------------------------------------------------------------------- 

685# list-contexts 

686# --------------------------------------------------------------------------- 

687 

688 

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

690@_host_option 

691@_time_options 

692@_provider_options 

693@click.option( 

694 "--format", 

695 "fmt", 

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

697 default="csv", 

698 callback=_list_fmt_callback, 

699 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]).", 

700) 

701@_bare_option 

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

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

704 with _stderr_ctx(bare): 

705 host = _resolve_host(host, no_cache) 

706 base_url = host.rstrip("/") + HISTORY_BASE 

707 provider = resolve_provider(host, base_url, provider, no_cache) 

708 time_params = apply_time_default(_build_time_params(from_, to, duration)) 

709 

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

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

712 click.echo(f"From: {time_params.get('from', '(server default)')}", err=True) 

713 click.echo(f"To: {time_params.get('to', '(server default)')}", err=True) 

714 click.echo( 

715 f"Duration: {time_params.get('duration', '(not specified)')}", err=True 

716 ) 

717 

718 params = {**time_params} 

719 if provider: 719 ↛ 722line 719 didn't jump to line 722 because the condition on line 719 was always true

720 params["provider"] = provider 

721 

722 try: 

723 resp = niquests.get(f"{base_url}/contexts", params=params, timeout=30) 

724 resp.raise_for_status() 

725 except niquests.RequestException as e: 

726 click.echo(f"Error fetching contexts: {api_error(e)}", err=True) 

727 sys.exit(1) 

728 

729 if fmt == "raw": 729 ↛ 730line 729 didn't jump to line 730 because the condition on line 729 was never true

730 click.echo(resp.text) 

731 else: 

732 contexts: list = resp.json() 

733 if fmt == "json": 733 ↛ 734line 733 didn't jump to line 734 because the condition on line 733 was never true

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

735 else: 

736 click.echo("context") 

737 for ctx in sorted(contexts): 

738 click.echo(ctx) 

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