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
« 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."""
3import csv
4import json
5import re
6import sys
7from datetime import UTC, datetime
8from pathlib import Path
9from urllib.parse import urlparse
11import click
12import niquests
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)
37_AUTO_OUTPUT = "__auto_output__"
39AGGREGATION_METHODS = [
40 "average",
41 "min",
42 "max",
43 "first",
44 "last",
45 "mid",
46 "middle_index",
47 "sma",
48 "ema",
49]
51# ---------------------------------------------------------------------------
52# Shared option decorators
53# ---------------------------------------------------------------------------
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'")
70_host_option = host_option
71_resolve_host = resolve_host
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
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
98_bare_option = bare_option
99_stderr_ctx = stderr_ctx
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
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.
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)
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
141 if has_inline:
142 return ",".join(paths), False
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
157# ---------------------------------------------------------------------------
158# CLI group
159# ---------------------------------------------------------------------------
162@click.group(context_settings={"help_option_names": ["-h", "--help"]})
163def cli():
164 """SignalK v2 history CLI."""
167# ---------------------------------------------------------------------------
168# query
169# ---------------------------------------------------------------------------
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.
255 Outputs to stdout by default. Use --output to write to a file.
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).
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.
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)
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))
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
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"
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}"
312 write_to_stdout = output is None or output == "-"
313 write_to_file = not write_to_stdout
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 )
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)
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)
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)
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)
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
356 url = f"{base_url}/values"
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
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)
380 indent = 2 if pretty else None
382 def _open_sink():
383 if write_to_file:
384 return open(output, "w", newline="")
385 return None
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 )
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)
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 )
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 )
454# ---------------------------------------------------------------------------
455# cardinality
456# ---------------------------------------------------------------------------
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.
498 Outputs a table of: path, distinct_values, min, max, average,
499 distinct_values_2_decimal_places, nulls.
501 For non-scalar values (e.g. navigation.position) min/max/average and
502 distinct_values_2_decimal_places are left blank.
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)
514 from_, to, duration = normalise_duration(duration, from_, to)
515 time_params = apply_time_default(_build_time_params(from_, to, duration))
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)
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)
539 if not resolved:
540 click.echo("No paths matched — nothing to query.", err=True)
541 sys.exit(1)
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
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)
560 stat_rows = compute_cardinality(resp.json())
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])
571 click.echo(f"{len(stat_rows)} path(s)", err=True)
574# ---------------------------------------------------------------------------
575# list-paths
576# ---------------------------------------------------------------------------
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))
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 )
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)
637# ---------------------------------------------------------------------------
638# list-providers
639# ---------------------------------------------------------------------------
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)
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)
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)
684# ---------------------------------------------------------------------------
685# list-contexts
686# ---------------------------------------------------------------------------
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))
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 )
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
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)
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)