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
« 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."""
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
13import click
14import niquests
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)
30_AUTO_OUTPUT = "__auto_output__"
32# ---------------------------------------------------------------------------
33# Shared option decorators
34# ---------------------------------------------------------------------------
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'")
51_host_option = host_option
52_resolve_host = resolve_host
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
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
79_bare_option = bare_option
80_stderr_ctx = stderr_ctx
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 )
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)
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)
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)}"
121# ---------------------------------------------------------------------------
122# CLI group
123# ---------------------------------------------------------------------------
126@click.group(context_settings={"help_option_names": ["-h", "--help"]})
127def cli():
128 """SignalK v2 history CLI."""
131# ---------------------------------------------------------------------------
132# query
133# ---------------------------------------------------------------------------
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.
219 Outputs to stdout by default. Use --output to write to a file.
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).
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.
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()
239 auto_name = output == _AUTO_OUTPUT
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"
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}"
269 write_to_stdout = output is None or output == "-"
270 write_to_file = not write_to_stdout
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 )
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)
285 with _exit_on_error("resolving paths"):
286 resolved = client.expand_paths(list(paths), time)
288 if not resolved:
289 click.echo("No paths matched — nothing to query.", err=True)
290 sys.exit(1)
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)
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
315 with _exit_on_error("fetching history"):
316 resp = client.fetch("values", params)
318 indent = 2 if pretty else None
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
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
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)
357# ---------------------------------------------------------------------------
358# cardinality
359# ---------------------------------------------------------------------------
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.
401 Outputs a table of: path, distinct_values, min, max, average,
402 distinct_values_2_decimal_places, nulls.
404 For non-scalar values (e.g. navigation.position) min/max/average and
405 distinct_values_2_decimal_places are left blank.
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()
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)
422 with _exit_on_error("resolving paths"):
423 resolved = client.expand_paths(list(paths) or ["*"], time)
425 if not resolved:
426 click.echo("No paths matched — nothing to query.", err=True)
427 sys.exit(1)
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 ]
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])
444 click.echo(f"{len(stat_rows)} path(s)", err=True)
447# ---------------------------------------------------------------------------
448# list-paths
449# ---------------------------------------------------------------------------
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()
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)
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)
492# ---------------------------------------------------------------------------
493# list-providers
494# ---------------------------------------------------------------------------
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)
514 with _exit_on_error("fetching providers"):
515 if fmt == "raw":
516 click.echo(client.fetch("_providers").text)
517 return
518 providers = client.providers()
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)
533# ---------------------------------------------------------------------------
534# list-contexts
535# ---------------------------------------------------------------------------
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()
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)
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)
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)