signalk-cli 2.0.1__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: signalk-cli
3
- Version: 2.0.1
3
+ Version: 2.1.0
4
4
  Summary: Query SignalK v2 APIs and export data as CSV, JSON or Apache Arrow Feather
5
5
  Keywords: signalk,sailing,marine,nmea,boating
6
6
  Author: jey burrows
@@ -71,6 +71,10 @@ Use `uv` to run without installing the module permanently, for example:
71
71
  uv run --with signalk-cli signalk_cli.history list-providers
72
72
  ```
73
73
 
74
+ ```bash
75
+ uv run --with signalk-cli signalk_cli.stream deltas navigation.position --follow
76
+ ```
77
+
74
78
  ## Running
75
79
 
76
80
  Run via `python -m signalk_cli.history <command>` or `python -m signalk_cli.stream <command>` or without installing the module with `uv run --with signalk-cli signalk_cli.history <command>`.
@@ -436,8 +440,10 @@ against the server's enumerated `/paths` list:
436
440
  Quote wildcarded paths (e.g. `'navigation.*'`) so the shell doesn't expand them
437
441
  against local filenames first.
438
442
 
439
- `deltas` always sends its own explicit subscribe message for `--context`,
440
- covering PATH arguments if given, otherwise every path (`*`). `--policy`,
443
+ `deltas` always sends its own explicit subscribe message for `--context`
444
+ (default `vessels.self`), covering PATH arguments if given, otherwise every
445
+ path (`*`). `--context` also accepts the SignalK wildcard: `'*'` or
446
+ `'vessels.*'` subscribes to every vessel, not just your own. `--policy`,
441
447
  `--period`, and `--min-period` control that subscription per the
442
448
  [Subscription Protocol](https://signalk.org/specification/1.8.2/doc/subscription_protocol.html)'s
443
449
  `policy`/`period`/`minPeriod` fields:
@@ -451,11 +457,23 @@ this CLI doesn't expose it: signalk-server rejects `full` outright ("Only
451
457
  delta format supported, using it") and always sends delta messages
452
458
  regardless, so the choice would be misleading.
453
459
 
454
- `--subscribe` is separate: it's the connection-level `subscribe` query
455
- parameter (`none`/`self`/`all`, default `none`), controlling only whether the
456
- server *additionally* auto-subscribes the connection at its own default
457
- policy/period — useful with `--subscribe all` to also receive other vessels'
458
- default-policy updates alongside your explicit subscription.
460
+ **`--subscribe` is a separate, easily-confused mechanism**: it's the
461
+ connection-level `subscribe` query parameter (`none`/`self`/`all`), which
462
+ controls whether the server *additionally* auto-subscribes the connection
463
+ at **its own** default policy/period — you don't get to choose the rate.
464
+ This CLI defaults it to `none`, diverging from the SignalK spec's own
465
+ default of `self`, because `deltas` always sends its own explicit `--context`
466
+ subscription anyway; leaving the connection-level default at `self` would
467
+ just double up on updates for your own vessel.
468
+
469
+ So there are two different ways to see every vessel, and they're not
470
+ interchangeable:
471
+
472
+ | Want | Use | Policy/period used |
473
+ |---|---|---|
474
+ | Just your own vessel | (defaults) | Yours (`--policy`/`--period`) |
475
+ | Every vessel, at your chosen rate | `--context '*'` | Yours (`--policy`/`--period`) |
476
+ | Every vessel, at whatever rate the server defaults to | `--subscribe all` | The server's own default |
459
477
 
460
478
  #### Options
461
479
 
@@ -470,6 +488,7 @@ default-policy updates alongside your explicit subscription.
470
488
  | `--min-period SECONDS` | — | Per-path subscribe `minPeriod` field, in seconds (converted to ms); only meaningful with `--policy instant` |
471
489
  | `--format [csv\|json\|raw\|feather]` | from extension, else csv | Output format. `json` is JSON Lines (one row object per line, suitable for a live stream). `raw` is the exact delta message text, one per line. `feather` requires `pip install 'signalk-cli[feather]'` and `--output` (cannot stream to stdout). |
472
490
  | `--no-header` | — | Suppress the CSV header row |
491
+ | `--include-meta` | — | Also emit rows for `meta` entries (units, description, zones, etc.), not just `values`. Adds a `kind` column (`value`/`meta`) to csv/json/feather output. Ignored for `--format raw`, which always includes meta as-is. |
473
492
  | `-o, --output [FILE]` | stdout | Write to a file. Omit the filename (`--output` alone) to auto-name as `signalk-stream-<server>-<timestamp>.<ext>`. Required for `--format feather`. |
474
493
  | `-f, --follow` | — | Keep streaming until interrupted (Ctrl-C) or `--count` is reached. Without this, print the next message then exit. |
475
494
  | `-n, --count N` | 1 without `--follow`, unlimited with it | Number of delta messages to output |
@@ -477,7 +496,7 @@ default-policy updates alongside your explicit subscription.
477
496
 
478
497
  #### Output formats
479
498
 
480
- **csv** / **json**: `timestamp, context, source, path, value` — one row per `path`/`value` pair in each delta's updates, written incrementally as messages arrive. Structured values (e.g. `navigation.position`) are JSON-encoded in the `value` column.
499
+ **csv** / **json**: `timestamp, context, source, path, value` — one row per `path`/`value` pair in each delta's updates, written incrementally as messages arrive. Structured values (e.g. `navigation.position`) are JSON-encoded in the `value` column. With `--include-meta`, a `kind` column (`value`/`meta`) is inserted before `value`, and `meta` entries (units, description, zones, etc. — also JSON-encoded) are included as rows too; without it, `meta` entries are silently dropped from csv/json/feather (they're still present in `raw`).
481
500
 
482
501
  **raw**: the exact delta message JSON as received from the server, one message per line.
483
502
 
@@ -43,6 +43,10 @@ Use `uv` to run without installing the module permanently, for example:
43
43
  uv run --with signalk-cli signalk_cli.history list-providers
44
44
  ```
45
45
 
46
+ ```bash
47
+ uv run --with signalk-cli signalk_cli.stream deltas navigation.position --follow
48
+ ```
49
+
46
50
  ## Running
47
51
 
48
52
  Run via `python -m signalk_cli.history <command>` or `python -m signalk_cli.stream <command>` or without installing the module with `uv run --with signalk-cli signalk_cli.history <command>`.
@@ -408,8 +412,10 @@ against the server's enumerated `/paths` list:
408
412
  Quote wildcarded paths (e.g. `'navigation.*'`) so the shell doesn't expand them
409
413
  against local filenames first.
410
414
 
411
- `deltas` always sends its own explicit subscribe message for `--context`,
412
- covering PATH arguments if given, otherwise every path (`*`). `--policy`,
415
+ `deltas` always sends its own explicit subscribe message for `--context`
416
+ (default `vessels.self`), covering PATH arguments if given, otherwise every
417
+ path (`*`). `--context` also accepts the SignalK wildcard: `'*'` or
418
+ `'vessels.*'` subscribes to every vessel, not just your own. `--policy`,
413
419
  `--period`, and `--min-period` control that subscription per the
414
420
  [Subscription Protocol](https://signalk.org/specification/1.8.2/doc/subscription_protocol.html)'s
415
421
  `policy`/`period`/`minPeriod` fields:
@@ -423,11 +429,23 @@ this CLI doesn't expose it: signalk-server rejects `full` outright ("Only
423
429
  delta format supported, using it") and always sends delta messages
424
430
  regardless, so the choice would be misleading.
425
431
 
426
- `--subscribe` is separate: it's the connection-level `subscribe` query
427
- parameter (`none`/`self`/`all`, default `none`), controlling only whether the
428
- server *additionally* auto-subscribes the connection at its own default
429
- policy/period — useful with `--subscribe all` to also receive other vessels'
430
- default-policy updates alongside your explicit subscription.
432
+ **`--subscribe` is a separate, easily-confused mechanism**: it's the
433
+ connection-level `subscribe` query parameter (`none`/`self`/`all`), which
434
+ controls whether the server *additionally* auto-subscribes the connection
435
+ at **its own** default policy/period — you don't get to choose the rate.
436
+ This CLI defaults it to `none`, diverging from the SignalK spec's own
437
+ default of `self`, because `deltas` always sends its own explicit `--context`
438
+ subscription anyway; leaving the connection-level default at `self` would
439
+ just double up on updates for your own vessel.
440
+
441
+ So there are two different ways to see every vessel, and they're not
442
+ interchangeable:
443
+
444
+ | Want | Use | Policy/period used |
445
+ |---|---|---|
446
+ | Just your own vessel | (defaults) | Yours (`--policy`/`--period`) |
447
+ | Every vessel, at your chosen rate | `--context '*'` | Yours (`--policy`/`--period`) |
448
+ | Every vessel, at whatever rate the server defaults to | `--subscribe all` | The server's own default |
431
449
 
432
450
  #### Options
433
451
 
@@ -442,6 +460,7 @@ default-policy updates alongside your explicit subscription.
442
460
  | `--min-period SECONDS` | — | Per-path subscribe `minPeriod` field, in seconds (converted to ms); only meaningful with `--policy instant` |
443
461
  | `--format [csv\|json\|raw\|feather]` | from extension, else csv | Output format. `json` is JSON Lines (one row object per line, suitable for a live stream). `raw` is the exact delta message text, one per line. `feather` requires `pip install 'signalk-cli[feather]'` and `--output` (cannot stream to stdout). |
444
462
  | `--no-header` | — | Suppress the CSV header row |
463
+ | `--include-meta` | — | Also emit rows for `meta` entries (units, description, zones, etc.), not just `values`. Adds a `kind` column (`value`/`meta`) to csv/json/feather output. Ignored for `--format raw`, which always includes meta as-is. |
445
464
  | `-o, --output [FILE]` | stdout | Write to a file. Omit the filename (`--output` alone) to auto-name as `signalk-stream-<server>-<timestamp>.<ext>`. Required for `--format feather`. |
446
465
  | `-f, --follow` | — | Keep streaming until interrupted (Ctrl-C) or `--count` is reached. Without this, print the next message then exit. |
447
466
  | `-n, --count N` | 1 without `--follow`, unlimited with it | Number of delta messages to output |
@@ -449,7 +468,7 @@ default-policy updates alongside your explicit subscription.
449
468
 
450
469
  #### Output formats
451
470
 
452
- **csv** / **json**: `timestamp, context, source, path, value` — one row per `path`/`value` pair in each delta's updates, written incrementally as messages arrive. Structured values (e.g. `navigation.position`) are JSON-encoded in the `value` column.
471
+ **csv** / **json**: `timestamp, context, source, path, value` — one row per `path`/`value` pair in each delta's updates, written incrementally as messages arrive. Structured values (e.g. `navigation.position`) are JSON-encoded in the `value` column. With `--include-meta`, a `kind` column (`value`/`meta`) is inserted before `value`, and `meta` entries (units, description, zones, etc. — also JSON-encoded) are included as rows too; without it, `meta` entries are silently dropped from csv/json/feather (they're still present in `raw`).
453
472
 
454
473
  **raw**: the exact delta message JSON as received from the server, one message per line.
455
474
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "signalk-cli"
3
- version = "2.0.1"
3
+ version = "2.1.0"
4
4
  description = "Query SignalK v2 APIs and export data as CSV, JSON or Apache Arrow Feather"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -102,13 +102,16 @@ mypy_path = ["./src"]
102
102
  [tool.uv]
103
103
  compile-bytecode = true
104
104
  managed = true
105
- exclude-newer = "5 days"
105
+ exclude-newer = "7 days"
106
106
  add-bounds = "major"
107
107
 
108
+ [tool.uv.exclude-newer-package]
109
+ uv_build = "2 days"
110
+
108
111
  [tool.uv.build-backend]
109
112
  module-root = "src"
110
113
  module-name = "signalk_cli"
111
114
 
112
115
  [build-system]
113
- requires = ["uv_build>=0.11.13,<0.12.0"]
116
+ requires = ["uv_build>=0.12.0,<0.13.0"]
114
117
  build-backend = "uv_build"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "signalk-cli"
3
- version = "2.0.1"
3
+ version = "2.1.0"
4
4
  description = "Query SignalK v2 APIs and export data as CSV, JSON or Apache Arrow Feather"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -101,11 +101,12 @@ mypy_path=["./src"]
101
101
  [tool.uv]
102
102
  compile-bytecode = true
103
103
  managed = true
104
- exclude-newer = "5 days"
104
+ exclude-newer = "7 days"
105
+ exclude-newer-package = {"uv_build"="2 days"}
105
106
  add-bounds = "major"
106
107
 
107
108
  [build-system]
108
- requires = ["uv_build>=0.11.13,<0.12.0"]
109
+ requires = ["uv_build>=0.12.0,<0.13.0"]
109
110
  build-backend = "uv_build"
110
111
 
111
112
 
@@ -49,18 +49,26 @@ def cli():
49
49
  @host_option
50
50
  @click.option("--no-cache", is_flag=True, help="Ignore cached host")
51
51
  @click.option(
52
- "--context", "-c", default="vessels.self", show_default=True, help="SignalK context"
52
+ "--context",
53
+ "-c",
54
+ default="vessels.self",
55
+ show_default=True,
56
+ help="SignalK context for the explicit subscription this command "
57
+ "always sends. Also accepts the SignalK wildcard '*' (or 'vessels.*') "
58
+ "to subscribe to every vessel at your own --policy/--period — the "
59
+ "alternative to --subscribe all, which uses the server's default rate.",
53
60
  )
54
61
  @click.option(
55
62
  "--subscribe",
56
63
  type=click.Choice(SUBSCRIBE_POLICIES, case_sensitive=False),
57
64
  default="none",
58
65
  show_default=True,
59
- help="Connection-level subscribe policy (none/self/all): whether the "
60
- "server also auto-subscribes this connection using its own default "
61
- "policy/period, in addition to the explicit subscription this command "
62
- "always sends for --context. Use 'all' to also receive other vessels' "
63
- "default-policy updates.",
66
+ help="Connection-level auto-subscribe at the server's OWN default "
67
+ "policy/period separate from, and in addition to, this command's "
68
+ "explicit --context subscription above. Defaults to 'none' (the "
69
+ "SignalK spec's own default is 'self') to avoid double-subscribing "
70
+ "your own context. 'all' adds other vessels at the server's rate; for "
71
+ "other vessels at your chosen rate, use --context '*' instead.",
64
72
  )
65
73
  @click.option(
66
74
  "--policy",
@@ -102,6 +110,13 @@ def cli():
102
110
  "pip install 'signalk-cli[feather]' and --output (cannot stream to stdout).",
103
111
  )
104
112
  @click.option("--no-header", is_flag=True, help="Suppress header row (CSV only)")
113
+ @click.option(
114
+ "--include-meta",
115
+ is_flag=True,
116
+ help="Also emit rows for 'meta' entries (units, description, zones, etc.), "
117
+ "not just 'values'. Adds a 'kind' column (value/meta) to csv/json/feather "
118
+ "output. Ignored for --format raw, which always includes meta as-is.",
119
+ )
105
120
  @click.option(
106
121
  "--output",
107
122
  "-o",
@@ -140,6 +155,7 @@ def deltas(
140
155
  min_period,
141
156
  fmt,
142
157
  no_header,
158
+ include_meta,
143
159
  output,
144
160
  follow,
145
161
  count,
@@ -233,7 +249,7 @@ def deltas(
233
249
  click.echo(f"Format: {fmt}", err=True)
234
250
 
235
251
  try:
236
- ws = open_stream(host, subscribe)
252
+ ws = open_stream(host, subscribe, timeout=None if follow else 30)
237
253
  except niquests.RequestException as e:
238
254
  click.echo(f"Error connecting to stream: {api_error(e)}", err=True)
239
255
  sys.exit(1)
@@ -252,7 +268,7 @@ def deltas(
252
268
  message_count = 0
253
269
  row_total = 0
254
270
  header_written = False
255
- feather_rows: list[tuple[str, str, str, str, str]] = []
271
+ feather_rows: list[tuple[str, ...]] = []
256
272
  fh = (
257
273
  open(output, "w", newline="") # noqa: SIM115
258
274
  if write_to_file and fmt != "feather"
@@ -263,26 +279,32 @@ def deltas(
263
279
  for raw, delta in iter_deltas(ws, effective_count):
264
280
  message_count += 1
265
281
  if fmt == "feather":
266
- feather_rows.extend(extract_delta_rows(delta))
282
+ feather_rows.extend(
283
+ extract_delta_rows(delta, include_meta=include_meta)
284
+ )
267
285
  row_total = len(feather_rows)
268
286
  elif fmt == "raw":
269
287
  click.echo(raw, file=sink)
270
288
  elif fmt == "json":
271
- row_total += write_json_delta(delta, sink)
289
+ row_total += write_json_delta(
290
+ delta, sink, include_meta=include_meta
291
+ )
272
292
  else:
273
293
  if not header_written and not no_header:
274
- write_csv_header(sink)
294
+ write_csv_header(sink, include_meta=include_meta)
275
295
  header_written = True
276
- row_total += write_csv_delta(delta, sink)
296
+ row_total += write_csv_delta(delta, sink, include_meta=include_meta)
277
297
  except KeyboardInterrupt:
278
298
  pass
299
+ except niquests.RequestException as e:
300
+ click.echo(f"Stream connection lost: {api_error(e)}", err=True)
279
301
  finally:
280
302
  ws.close()
281
303
  if fh:
282
304
  fh.close()
283
305
 
284
306
  if fmt == "feather":
285
- write_feather_rows(feather_rows, output)
307
+ write_feather_rows(feather_rows, output, include_meta=include_meta)
286
308
 
287
309
  if write_to_file:
288
310
  click.echo(f"Wrote {output}", err=True)
@@ -0,0 +1,108 @@
1
+ """Row extraction and CSV/JSON/Feather writers for SignalK delta messages."""
2
+
3
+ import csv
4
+ import json
5
+ from typing import IO
6
+
7
+ FEATHER_EXTENSIONS = {".feather", ".arrow", ".fea"}
8
+
9
+
10
+ def _normalize_value(value: object) -> str:
11
+ if isinstance(value, (dict, list)):
12
+ return json.dumps(value)
13
+ if value is None:
14
+ return ""
15
+ return str(value)
16
+
17
+
18
+ def extract_delta_rows(
19
+ delta: dict, *, include_meta: bool = False
20
+ ) -> list[tuple[str, ...]]:
21
+ """Flatten a single delta message into rows.
22
+
23
+ Without `include_meta`, rows are (timestamp, context, source, path,
24
+ value) from each update's "values" entries. With `include_meta`, a
25
+ "kind" column ("value"/"meta") is inserted before "value", and each
26
+ update's "meta" entries are included too — per the Streaming API spec,
27
+ "meta" entries have the same path/value shape but "value" is a metadata
28
+ object (units, description, zones, etc.), not a telemetry reading.
29
+ """
30
+ context = delta.get("context", "")
31
+ rows: list[tuple[str, ...]] = []
32
+ for update in delta.get("updates", []):
33
+ timestamp = update.get("timestamp", "")
34
+ source = update.get("$source") or json.dumps(update.get("source", {}))
35
+ for entry in update.get("values", []):
36
+ path = entry.get("path", "")
37
+ value = _normalize_value(entry.get("value"))
38
+ if include_meta:
39
+ rows.append((timestamp, context, source, path, "value", value))
40
+ else:
41
+ rows.append((timestamp, context, source, path, value))
42
+ if include_meta:
43
+ for entry in update.get("meta", []):
44
+ path = entry.get("path", "")
45
+ value = _normalize_value(entry.get("value"))
46
+ rows.append((timestamp, context, source, path, "meta", value))
47
+ return rows
48
+
49
+
50
+ CSV_COLUMNS = ["timestamp", "context", "source", "path", "value"]
51
+ CSV_COLUMNS_WITH_KIND = ["timestamp", "context", "source", "path", "kind", "value"]
52
+
53
+
54
+ def _columns(include_meta: bool) -> list[str]:
55
+ return CSV_COLUMNS_WITH_KIND if include_meta else CSV_COLUMNS
56
+
57
+
58
+ def write_csv_header(sink: IO[str], *, include_meta: bool = False) -> None:
59
+ csv.writer(sink).writerow(_columns(include_meta))
60
+ sink.flush()
61
+
62
+
63
+ def write_csv_delta(delta: dict, sink: IO[str], *, include_meta: bool = False) -> int:
64
+ """Write one delta's rows as CSV lines. Returns the number of rows written."""
65
+ rows = extract_delta_rows(delta, include_meta=include_meta)
66
+ writer = csv.writer(sink)
67
+ for row in rows:
68
+ writer.writerow(row)
69
+ sink.flush()
70
+ return len(rows)
71
+
72
+
73
+ def write_json_delta(delta: dict, sink: IO[str], *, include_meta: bool = False) -> int:
74
+ """Write one delta's rows as JSON Lines (one row object per line). Returns row count."""
75
+ rows = extract_delta_rows(delta, include_meta=include_meta)
76
+ columns = _columns(include_meta)
77
+ for row in rows:
78
+ sink.write(json.dumps(dict(zip(columns, row))))
79
+ sink.write("\n")
80
+ sink.flush()
81
+ return len(rows)
82
+
83
+
84
+ def write_feather_rows(
85
+ rows: list[tuple[str, ...]], output: str, *, include_meta: bool = False
86
+ ) -> int:
87
+ """Write accumulated delta rows as Feather. Returns the number of rows written.
88
+
89
+ Unlike CSV/JSON, Feather cannot be appended to incrementally — callers must
90
+ buffer rows across messages and call this once at the end of the session.
91
+ """
92
+ try:
93
+ import pyarrow as pa
94
+ from pyarrow import feather
95
+ except ImportError:
96
+ raise ImportError(
97
+ "pyarrow is required for Feather output: pip install 'signalk-cli[feather]'"
98
+ ) from None
99
+ columns = _columns(include_meta)
100
+ row_columns = list(zip(*rows)) if rows else [()] * len(columns)
101
+ table = pa.table(
102
+ {
103
+ name: pa.array(values, type=pa.string())
104
+ for name, values in zip(columns, row_columns)
105
+ }
106
+ )
107
+ feather.write_feather(table, output)
108
+ return len(rows)
@@ -24,9 +24,15 @@ def to_ws_url(host: str) -> str:
24
24
  return urlunparse((scheme, parsed.netloc, STREAM_PATH, "", "", ""))
25
25
 
26
26
 
27
- def open_stream(host: str, subscribe: str, timeout: float = 30):
27
+ def open_stream(host: str, subscribe: str, timeout: float | None = 30):
28
28
  """Open a WebSocket connection to the SignalK streaming endpoint.
29
29
 
30
+ `timeout` sets the socket's read timeout for the life of the connection —
31
+ every subsequent `next_payload()` read reuses it, not just the initial
32
+ handshake. Pass `None` when the caller intends to block indefinitely
33
+ between messages (e.g. `--follow`), since deltas can legitimately go
34
+ quiet for longer than any fixed timeout depending on subscribe policy.
35
+
30
36
  Returns the underlying HTTP extension object, used to send/receive frames
31
37
  via `send_payload`/`next_payload`.
32
38
  """
@@ -1,89 +0,0 @@
1
- """Row extraction and CSV/JSON/Feather writers for SignalK delta messages."""
2
-
3
- import csv
4
- import json
5
- from typing import IO
6
-
7
- FEATHER_EXTENSIONS = {".feather", ".arrow", ".fea"}
8
-
9
-
10
- def extract_delta_rows(delta: dict) -> list[tuple[str, str, str, str, str]]:
11
- """Flatten a single delta message into (timestamp, context, source, path, value) rows."""
12
- context = delta.get("context", "")
13
- rows = []
14
- for update in delta.get("updates", []):
15
- timestamp = update.get("timestamp", "")
16
- source = update.get("$source") or json.dumps(update.get("source", {}))
17
- for entry in update.get("values", []):
18
- path = entry.get("path", "")
19
- value = entry.get("value")
20
- if isinstance(value, (dict, list)):
21
- value = json.dumps(value)
22
- elif value is None:
23
- value = ""
24
- else:
25
- value = str(value)
26
- rows.append((timestamp, context, source, path, value))
27
- return rows
28
-
29
-
30
- CSV_COLUMNS = ["timestamp", "context", "source", "path", "value"]
31
-
32
-
33
- def write_csv_header(sink: IO[str]) -> None:
34
- csv.writer(sink).writerow(CSV_COLUMNS)
35
- sink.flush()
36
-
37
-
38
- def write_csv_delta(delta: dict, sink: IO[str]) -> int:
39
- """Write one delta's rows as CSV lines. Returns the number of rows written."""
40
- rows = extract_delta_rows(delta)
41
- writer = csv.writer(sink)
42
- for row in rows:
43
- writer.writerow(row)
44
- sink.flush()
45
- return len(rows)
46
-
47
-
48
- def write_json_delta(delta: dict, sink: IO[str]) -> int:
49
- """Write one delta's rows as JSON Lines (one row object per line). Returns row count."""
50
- rows = extract_delta_rows(delta)
51
- for ts, context, source, path, value in rows:
52
- sink.write(
53
- json.dumps(
54
- {
55
- "timestamp": ts,
56
- "context": context,
57
- "source": source,
58
- "path": path,
59
- "value": value,
60
- }
61
- )
62
- )
63
- sink.write("\n")
64
- sink.flush()
65
- return len(rows)
66
-
67
-
68
- def write_feather_rows(rows: list[tuple[str, str, str, str, str]], output: str) -> int:
69
- """Write accumulated delta rows as Feather. Returns the number of rows written.
70
-
71
- Unlike CSV/JSON, Feather cannot be appended to incrementally — callers must
72
- buffer rows across messages and call this once at the end of the session.
73
- """
74
- try:
75
- import pyarrow as pa
76
- from pyarrow import feather
77
- except ImportError:
78
- raise ImportError(
79
- "pyarrow is required for Feather output: pip install 'signalk-cli[feather]'"
80
- ) from None
81
- columns = list(zip(*rows)) if rows else [()] * len(CSV_COLUMNS)
82
- table = pa.table(
83
- {
84
- name: pa.array(values, type=pa.string())
85
- for name, values in zip(CSV_COLUMNS, columns)
86
- }
87
- )
88
- feather.write_feather(table, output)
89
- return len(rows)