signalk-cli 2.2.1__tar.gz → 3.0.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.
- {signalk_cli-2.2.1 → signalk_cli-3.0.0}/PKG-INFO +30 -11
- {signalk_cli-2.2.1 → signalk_cli-3.0.0}/README.md +22 -7
- {signalk_cli-2.2.1 → signalk_cli-3.0.0}/pyproject.toml +20 -6
- {signalk_cli-2.2.1 → signalk_cli-3.0.0}/pyproject.toml.orig +19 -7
- signalk_cli-3.0.0/src/signalk_cli/__init__.py +33 -0
- signalk_cli-3.0.0/src/signalk_cli/_arrow.py +147 -0
- signalk_cli-3.0.0/src/signalk_cli/_cli.py +82 -0
- signalk_cli-3.0.0/src/signalk_cli/errors.py +37 -0
- signalk_cli-3.0.0/src/signalk_cli/history/__init__.py +25 -0
- signalk_cli-3.0.0/src/signalk_cli/history/_results.py +230 -0
- signalk_cli-3.0.0/src/signalk_cli/history/_time.py +177 -0
- signalk_cli-3.0.0/src/signalk_cli/history/api.py +515 -0
- signalk_cli-3.0.0/src/signalk_cli/history/cli.py +573 -0
- signalk_cli-3.0.0/src/signalk_cli/history/output.py +84 -0
- signalk_cli-3.0.0/src/signalk_cli/net.py +72 -0
- signalk_cli-3.0.0/src/signalk_cli/py.typed +0 -0
- signalk_cli-3.0.0/src/signalk_cli/stream/__init__.py +29 -0
- signalk_cli-3.0.0/src/signalk_cli/stream/api.py +401 -0
- {signalk_cli-2.2.1 → signalk_cli-3.0.0}/src/signalk_cli/stream/cli.py +46 -57
- signalk_cli-3.0.0/src/signalk_cli/stream/output.py +61 -0
- signalk_cli-2.2.1/src/signalk_cli/__init__.py +0 -1
- signalk_cli-2.2.1/src/signalk_cli/history/__init__.py +0 -33
- signalk_cli-2.2.1/src/signalk_cli/history/cli.py +0 -741
- signalk_cli-2.2.1/src/signalk_cli/history/history_api.py +0 -253
- signalk_cli-2.2.1/src/signalk_cli/history/output.py +0 -421
- signalk_cli-2.2.1/src/signalk_cli/net.py +0 -142
- signalk_cli-2.2.1/src/signalk_cli/stream/__init__.py +0 -21
- signalk_cli-2.2.1/src/signalk_cli/stream/output.py +0 -181
- signalk_cli-2.2.1/src/signalk_cli/stream/stream_api.py +0 -106
- {signalk_cli-2.2.1 → signalk_cli-3.0.0}/src/signalk_cli/__main__.py +0 -0
- {signalk_cli-2.2.1 → signalk_cli-3.0.0}/src/signalk_cli/history/__main__.py +0 -0
- {signalk_cli-2.2.1 → signalk_cli-3.0.0}/src/signalk_cli/stream/__main__.py +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: signalk-cli
|
|
3
|
-
Version:
|
|
4
|
-
Summary: Query SignalK v2 APIs and export data as CSV, JSON or Apache Arrow Feather
|
|
5
|
-
Keywords: signalk,sailing,marine,nmea,
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Query SignalK v2 APIs and export data as CSV, JSON or Apache Arrow Feather from command line or as a Python API
|
|
5
|
+
Keywords: signalk,sailing,marine,boating,nmea,dataframe,cli,pyarrow,nanoarrow
|
|
6
6
|
Author: jey burrows
|
|
7
7
|
Author-email: jey burrows <jrb@rhizomatics.org.uk>
|
|
8
8
|
License-Expression: Apache-2.0
|
|
@@ -15,14 +15,18 @@ Classifier: Programming Language :: Python
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.14
|
|
17
17
|
Requires-Dist: click>=8.3.3
|
|
18
|
+
Requires-Dist: nanoarrow>=0.9.0,<1.0.0
|
|
18
19
|
Requires-Dist: niquests[ws]>=2.28
|
|
19
|
-
Requires-Dist:
|
|
20
|
+
Requires-Dist: niquests[ws]>=3.21.0 ; sys_platform == 'emscripten'
|
|
21
|
+
Requires-Dist: zeroconf>=0.149.16,<0.152.0 ; sys_platform != 'emscripten'
|
|
22
|
+
Requires-Dist: pyarrow>=17.0 ; extra == 'arrow'
|
|
20
23
|
Requires-Dist: pyarrow>=17.0 ; extra == 'feather'
|
|
21
24
|
Requires-Python: >=3.13
|
|
22
25
|
Project-URL: Homepage, https://signalk-cli.rhizomatics.org.uk
|
|
23
26
|
Project-URL: Repository, https://github.com/rhizomatics/signalk-cli
|
|
24
27
|
Project-URL: Issues, https://github.com/rhizomatics/signalk-cli/issues
|
|
25
28
|
Project-URL: Changelog, https://github.com/rhizomatics/signalk-cli/blob/main/CHANGELOG.md
|
|
29
|
+
Provides-Extra: arrow
|
|
26
30
|
Provides-Extra: feather
|
|
27
31
|
Description-Content-Type: text/markdown
|
|
28
32
|
|
|
@@ -41,7 +45,7 @@ Description-Content-Type: text/markdown
|
|
|
41
45
|
[](https://github.com/rhizomatics/signalk-cli/actions/workflows/dependabot/dependabot-updates)
|
|
42
46
|
[](https://boat-tech-directory.rhizomatics.org.uk)
|
|
43
47
|
|
|
44
|
-
Query and explore NMEA and other boat data from SignalK APIs using the command line
|
|
48
|
+
Query and explore NMEA and other boat data from SignalK APIs using the command line or the Python API. Export data as CSV, Apache Arrow Feather, or JSON or use as dataframes in scripts or data notebooks.
|
|
45
49
|
|
|
46
50
|
APIs supported:
|
|
47
51
|
|
|
@@ -71,17 +75,18 @@ Python is required to run this, version 3.13 or above. [uv](https://docs.astral.
|
|
|
71
75
|
|
|
72
76
|
For Apache Arrow Feather export, use the optional dependency: ```pip install 'signalk-cli[feather]'```
|
|
73
77
|
|
|
74
|
-
### Pyodide /
|
|
78
|
+
### Pyodide / WebAssembly
|
|
75
79
|
|
|
76
|
-
|
|
80
|
+
The same wheel installs in Pyodide (e.g. marimo in the browser). Platform markers skip `zeroconf`, which can't run there, and require `niquests>=3.21.0`:
|
|
77
81
|
|
|
78
82
|
```python
|
|
79
83
|
import micropip
|
|
80
|
-
await micropip.install(
|
|
81
|
-
await micropip.install("signalk-cli", deps=False)
|
|
84
|
+
await micropip.install("signalk-cli")
|
|
82
85
|
```
|
|
83
86
|
|
|
84
|
-
Without `zeroconf
|
|
87
|
+
Without `zeroconf` there's no mDNS discovery, so the host must be given with `--host` or `SIGNALK_HOST` (or come from the cache).
|
|
88
|
+
|
|
89
|
+
See [signalk-datalab-plugin](https://github.com/rhizomatics/signalk-datalab-plugin) for a working example of this.
|
|
85
90
|
|
|
86
91
|
### Local Copy
|
|
87
92
|
|
|
@@ -105,6 +110,20 @@ uv run --with signalk-cli signalk_cli.history list-providers
|
|
|
105
110
|
uv run --with signalk-cli signalk_cli.stream deltas navigation.position --follow
|
|
106
111
|
```
|
|
107
112
|
|
|
113
|
+
## Python library
|
|
114
|
+
|
|
115
|
+
Everything the CLI does is also available from Python, returning tables that load straight into polars, pandas, pyarrow or DuckDB:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from signalk_cli import HistoryClient, TimeRange
|
|
119
|
+
import polars as pl
|
|
120
|
+
|
|
121
|
+
with HistoryClient("http://boat.local:3000") as client:
|
|
122
|
+
df = pl.DataFrame(client.query(["navigation.*"], TimeRange(duration="P1D")))
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
See the [Python library guide](https://signalk-cli.rhizomatics.org.uk/library/) and API reference.
|
|
126
|
+
|
|
108
127
|
## Running
|
|
109
128
|
|
|
110
129
|
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>`.
|
|
@@ -150,7 +169,7 @@ python -m signalk_cli.history query [OPTIONS] PATH...
|
|
|
150
169
|
**PATH** arguments may be:
|
|
151
170
|
|
|
152
171
|
- **Literal paths** — e.g. `navigation.speedOverGround`
|
|
153
|
-
- **Regex / glob patterns** — any argument containing
|
|
172
|
+
- **Regex / glob patterns** — any argument containing a pattern character (`*`, `?`, `+`, `[`, `(`, `{`, `|`, `^`, `$` or `\`) is matched against the server's `/paths` endpoint. A `.` on its own doesn't count, so a plain dotted path is always literal. Bare `*` is treated as a glob wildcard.
|
|
154
173
|
- **Inline path specs** — `path:method` or `path:method:param`, e.g. `navigation.speedOverGround:sma:5`. These pass through to the server unchanged.
|
|
155
174
|
|
|
156
175
|
#### Options
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
[](https://github.com/rhizomatics/signalk-cli/actions/workflows/dependabot/dependabot-updates)
|
|
14
14
|
[](https://boat-tech-directory.rhizomatics.org.uk)
|
|
15
15
|
|
|
16
|
-
Query and explore NMEA and other boat data from SignalK APIs using the command line
|
|
16
|
+
Query and explore NMEA and other boat data from SignalK APIs using the command line or the Python API. Export data as CSV, Apache Arrow Feather, or JSON or use as dataframes in scripts or data notebooks.
|
|
17
17
|
|
|
18
18
|
APIs supported:
|
|
19
19
|
|
|
@@ -43,17 +43,18 @@ Python is required to run this, version 3.13 or above. [uv](https://docs.astral.
|
|
|
43
43
|
|
|
44
44
|
For Apache Arrow Feather export, use the optional dependency: ```pip install 'signalk-cli[feather]'```
|
|
45
45
|
|
|
46
|
-
### Pyodide /
|
|
46
|
+
### Pyodide / WebAssembly
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
The same wheel installs in Pyodide (e.g. marimo in the browser). Platform markers skip `zeroconf`, which can't run there, and require `niquests>=3.21.0`:
|
|
49
49
|
|
|
50
50
|
```python
|
|
51
51
|
import micropip
|
|
52
|
-
await micropip.install(
|
|
53
|
-
await micropip.install("signalk-cli", deps=False)
|
|
52
|
+
await micropip.install("signalk-cli")
|
|
54
53
|
```
|
|
55
54
|
|
|
56
|
-
Without `zeroconf
|
|
55
|
+
Without `zeroconf` there's no mDNS discovery, so the host must be given with `--host` or `SIGNALK_HOST` (or come from the cache).
|
|
56
|
+
|
|
57
|
+
See [signalk-datalab-plugin](https://github.com/rhizomatics/signalk-datalab-plugin) for a working example of this.
|
|
57
58
|
|
|
58
59
|
### Local Copy
|
|
59
60
|
|
|
@@ -77,6 +78,20 @@ uv run --with signalk-cli signalk_cli.history list-providers
|
|
|
77
78
|
uv run --with signalk-cli signalk_cli.stream deltas navigation.position --follow
|
|
78
79
|
```
|
|
79
80
|
|
|
81
|
+
## Python library
|
|
82
|
+
|
|
83
|
+
Everything the CLI does is also available from Python, returning tables that load straight into polars, pandas, pyarrow or DuckDB:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from signalk_cli import HistoryClient, TimeRange
|
|
87
|
+
import polars as pl
|
|
88
|
+
|
|
89
|
+
with HistoryClient("http://boat.local:3000") as client:
|
|
90
|
+
df = pl.DataFrame(client.query(["navigation.*"], TimeRange(duration="P1D")))
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
See the [Python library guide](https://signalk-cli.rhizomatics.org.uk/library/) and API reference.
|
|
94
|
+
|
|
80
95
|
## Running
|
|
81
96
|
|
|
82
97
|
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>`.
|
|
@@ -122,7 +137,7 @@ python -m signalk_cli.history query [OPTIONS] PATH...
|
|
|
122
137
|
**PATH** arguments may be:
|
|
123
138
|
|
|
124
139
|
- **Literal paths** — e.g. `navigation.speedOverGround`
|
|
125
|
-
- **Regex / glob patterns** — any argument containing
|
|
140
|
+
- **Regex / glob patterns** — any argument containing a pattern character (`*`, `?`, `+`, `[`, `(`, `{`, `|`, `^`, `$` or `\`) is matched against the server's `/paths` endpoint. A `.` on its own doesn't count, so a plain dotted path is always literal. Bare `*` is treated as a glob wildcard.
|
|
126
141
|
- **Inline path specs** — `path:method` or `path:method:param`, e.g. `navigation.speedOverGround:sma:5`. These pass through to the server unchanged.
|
|
127
142
|
|
|
128
143
|
#### Options
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "signalk-cli"
|
|
3
|
-
version = "
|
|
4
|
-
description = "Query SignalK v2 APIs and export data as CSV, JSON or Apache Arrow Feather"
|
|
3
|
+
version = "3.0.0"
|
|
4
|
+
description = "Query SignalK v2 APIs and export data as CSV, JSON or Apache Arrow Feather from command line or as a Python API"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
7
7
|
license = "Apache-2.0"
|
|
@@ -9,8 +9,12 @@ keywords = [
|
|
|
9
9
|
"signalk",
|
|
10
10
|
"sailing",
|
|
11
11
|
"marine",
|
|
12
|
-
"nmea",
|
|
13
12
|
"boating",
|
|
13
|
+
"nmea",
|
|
14
|
+
"dataframe",
|
|
15
|
+
"cli",
|
|
16
|
+
"pyarrow",
|
|
17
|
+
"nanoarrow",
|
|
14
18
|
]
|
|
15
19
|
classifiers = [
|
|
16
20
|
"Development Status :: 5 - Production/Stable",
|
|
@@ -24,8 +28,10 @@ classifiers = [
|
|
|
24
28
|
]
|
|
25
29
|
dependencies = [
|
|
26
30
|
"click>=8.3.3",
|
|
31
|
+
"nanoarrow>=0.9.0,<1.0.0",
|
|
27
32
|
"niquests[ws]>=2.28",
|
|
28
|
-
"
|
|
33
|
+
"niquests[ws]>=3.21.0; sys_platform == 'emscripten'",
|
|
34
|
+
"zeroconf>=0.149.16,<0.152.0; sys_platform != 'emscripten'",
|
|
29
35
|
]
|
|
30
36
|
|
|
31
37
|
[[project.authors]]
|
|
@@ -34,6 +40,7 @@ email = "jrb@rhizomatics.org.uk"
|
|
|
34
40
|
|
|
35
41
|
[project.optional-dependencies]
|
|
36
42
|
feather = ["pyarrow>=17.0"]
|
|
43
|
+
arrow = ["pyarrow>=17.0"]
|
|
37
44
|
|
|
38
45
|
[project.urls]
|
|
39
46
|
Homepage = "https://signalk-cli.rhizomatics.org.uk"
|
|
@@ -58,6 +65,9 @@ dev = [
|
|
|
58
65
|
"genbadge[all]",
|
|
59
66
|
"icdiff",
|
|
60
67
|
"codespell",
|
|
68
|
+
"polars>=1.44.2,<2.0.0",
|
|
69
|
+
"duckdb>=1.5.5,<2.0.0",
|
|
70
|
+
"pandas>=3.0.6,<4.0.0",
|
|
61
71
|
]
|
|
62
72
|
docs = [
|
|
63
73
|
"properdocs",
|
|
@@ -71,6 +81,7 @@ docs = [
|
|
|
71
81
|
"pngquant",
|
|
72
82
|
"mkdocs-htmlproofer-plugin",
|
|
73
83
|
"mkdocs-llmstxt>=0.5.0,<0.6.0",
|
|
84
|
+
"mkdocstrings[python]>=1.0.6,<2.0.0",
|
|
74
85
|
]
|
|
75
86
|
|
|
76
87
|
[tool.bandit]
|
|
@@ -95,7 +106,7 @@ branch = true
|
|
|
95
106
|
source = ["src"]
|
|
96
107
|
|
|
97
108
|
[tool.coverage.report]
|
|
98
|
-
fail_under =
|
|
109
|
+
fail_under = 90
|
|
99
110
|
|
|
100
111
|
[tool.coverage.xml]
|
|
101
112
|
output = "cov.xml"
|
|
@@ -113,7 +124,10 @@ ignore-words-list = "hass,referer,uint"
|
|
|
113
124
|
mypy_path = ["./src"]
|
|
114
125
|
|
|
115
126
|
[[tool.mypy.overrides]]
|
|
116
|
-
module =
|
|
127
|
+
module = [
|
|
128
|
+
"pyarrow.*",
|
|
129
|
+
"nanoarrow.*",
|
|
130
|
+
]
|
|
117
131
|
ignore_missing_imports = true
|
|
118
132
|
|
|
119
133
|
[tool.ty.analysis]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "signalk-cli"
|
|
3
|
-
version = "
|
|
4
|
-
description = "Query SignalK v2 APIs and export data as CSV, JSON or Apache Arrow Feather"
|
|
3
|
+
version = "3.0.0"
|
|
4
|
+
description = "Query SignalK v2 APIs and export data as CSV, JSON or Apache Arrow Feather from command line or as a Python API"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
7
7
|
license="Apache-2.0"
|
|
@@ -9,7 +9,9 @@ authors = [
|
|
|
9
9
|
{ name = "jey burrows", email = "jrb@rhizomatics.org.uk" }
|
|
10
10
|
]
|
|
11
11
|
|
|
12
|
-
keywords = ["signalk", "sailing", "marine", "
|
|
12
|
+
keywords = ["signalk", "sailing", "marine", "boating",
|
|
13
|
+
"nmea",
|
|
14
|
+
"dataframe","cli","pyarrow","nanoarrow"]
|
|
13
15
|
classifiers = [
|
|
14
16
|
"Development Status :: 5 - Production/Stable",
|
|
15
17
|
"Environment :: Console",
|
|
@@ -23,12 +25,18 @@ classifiers = [
|
|
|
23
25
|
|
|
24
26
|
dependencies = [
|
|
25
27
|
"click>=8.3.3",
|
|
28
|
+
"nanoarrow>=0.9.0,<1.0.0",
|
|
26
29
|
"niquests[ws]>=2.28",
|
|
27
|
-
|
|
30
|
+
# Pyodide/WASM: niquests needs >=3.21 there, and zeroconf can't run (no raw sockets)
|
|
31
|
+
"niquests[ws]>=3.21.0; sys_platform == 'emscripten'",
|
|
32
|
+
"zeroconf>=0.149.16,<0.152.0; sys_platform != 'emscripten'",
|
|
28
33
|
]
|
|
29
34
|
|
|
30
35
|
[project.optional-dependencies]
|
|
36
|
+
# pyarrow, only needed to write Feather files from the CLI
|
|
31
37
|
feather = ["pyarrow>=17.0"]
|
|
38
|
+
# alias of feather
|
|
39
|
+
arrow = ["pyarrow>=17.0"]
|
|
32
40
|
[dependency-groups]
|
|
33
41
|
dev = [
|
|
34
42
|
"ruff>=0.16.0",
|
|
@@ -41,7 +49,10 @@ dev = [
|
|
|
41
49
|
"coverage",
|
|
42
50
|
"genbadge[all]",
|
|
43
51
|
"icdiff",
|
|
44
|
-
"codespell"
|
|
52
|
+
"codespell",
|
|
53
|
+
"polars>=1.44.2,<2.0.0",
|
|
54
|
+
"duckdb>=1.5.5,<2.0.0",
|
|
55
|
+
"pandas>=3.0.6,<4.0.0",
|
|
45
56
|
]
|
|
46
57
|
|
|
47
58
|
docs=[
|
|
@@ -56,6 +67,7 @@ docs=[
|
|
|
56
67
|
"pngquant",
|
|
57
68
|
"mkdocs-htmlproofer-plugin",
|
|
58
69
|
"mkdocs-llmstxt>=0.5.0,<0.6.0",
|
|
70
|
+
"mkdocstrings[python]>=1.0.6,<2.0.0",
|
|
59
71
|
]
|
|
60
72
|
|
|
61
73
|
[project.urls]
|
|
@@ -95,7 +107,7 @@ branch = true
|
|
|
95
107
|
source = ["src"]
|
|
96
108
|
|
|
97
109
|
[tool.coverage.report]
|
|
98
|
-
fail_under=
|
|
110
|
+
fail_under=90
|
|
99
111
|
|
|
100
112
|
[tool.coverage.xml]
|
|
101
113
|
output="cov.xml"
|
|
@@ -113,7 +125,7 @@ ignore-words-list="hass,referer,uint"
|
|
|
113
125
|
mypy_path=["./src"]
|
|
114
126
|
|
|
115
127
|
[[tool.mypy.overrides]]
|
|
116
|
-
module = "pyarrow.*"
|
|
128
|
+
module = ["pyarrow.*", "nanoarrow.*"]
|
|
117
129
|
ignore_missing_imports = true
|
|
118
130
|
|
|
119
131
|
[tool.ty.analysis]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""signalk_cli — Python clients and CLI for SignalK APIs.
|
|
2
|
+
|
|
3
|
+
The clients are importable from here or from their packages:
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from signalk_cli import HistoryClient, StreamClient, TimeRange
|
|
7
|
+
```
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from ._arrow import ArrowTable
|
|
13
|
+
from .errors import SignalKError
|
|
14
|
+
from .history import HistoryClient, HistoryResult, TimeRange
|
|
15
|
+
from .net import discover_host
|
|
16
|
+
from .stream import DeltaMessage, DeltaRow, DeltaStream, StreamClient
|
|
17
|
+
|
|
18
|
+
# Library convention: stay silent unless the application configures logging.
|
|
19
|
+
# The CLI attaches its own stderr handler per command.
|
|
20
|
+
logging.getLogger("signalk_cli").addHandler(logging.NullHandler())
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ArrowTable",
|
|
24
|
+
"DeltaMessage",
|
|
25
|
+
"DeltaRow",
|
|
26
|
+
"DeltaStream",
|
|
27
|
+
"HistoryClient",
|
|
28
|
+
"HistoryResult",
|
|
29
|
+
"SignalKError",
|
|
30
|
+
"StreamClient",
|
|
31
|
+
"TimeRange",
|
|
32
|
+
"discover_host",
|
|
33
|
+
]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""A small Arrow table built with nanoarrow, handed to dataframe libraries via the PyCapsule interface."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import nanoarrow as na
|
|
9
|
+
|
|
10
|
+
_TIMESTAMP = na.timestamp("us", timezone="UTC")
|
|
11
|
+
|
|
12
|
+
FEATHER_EXTENSIONS = {".feather", ".arrow", ".fea"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _is_number(v: object) -> bool:
|
|
16
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def as_text(v: object) -> str | None:
|
|
20
|
+
if v is None or isinstance(v, str):
|
|
21
|
+
return v
|
|
22
|
+
if isinstance(v, (dict, list)):
|
|
23
|
+
return json.dumps(v)
|
|
24
|
+
return str(v)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _epoch_us(ts: str | datetime | None) -> int | None:
|
|
28
|
+
if ts is None:
|
|
29
|
+
return None
|
|
30
|
+
dt = datetime.fromisoformat(ts) if isinstance(ts, str) else ts
|
|
31
|
+
return round(dt.timestamp() * 1_000_000)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def infer_column(values: Sequence[Any]) -> tuple[list, Any]:
|
|
35
|
+
"""Pick one Arrow type for a column of JSON values: float64, bool, or string.
|
|
36
|
+
|
|
37
|
+
Numbers (and nulls) become float64, booleans stay bool, and anything else
|
|
38
|
+
— strings, objects, arrays, or a mix — becomes a string column, with
|
|
39
|
+
objects and arrays JSON-encoded.
|
|
40
|
+
"""
|
|
41
|
+
present = [v for v in values if v is not None]
|
|
42
|
+
if all(_is_number(v) for v in present):
|
|
43
|
+
return [None if v is None else float(v) for v in values], na.float64()
|
|
44
|
+
if all(isinstance(v, bool) for v in present):
|
|
45
|
+
return list(values), na.bool_()
|
|
46
|
+
return [as_text(v) for v in values], na.string()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ArrowTable:
|
|
50
|
+
"""A table of query results, ready to load into any Arrow-aware dataframe library.
|
|
51
|
+
|
|
52
|
+
It implements the [Arrow PyCapsule interface](https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html),
|
|
53
|
+
so it can be passed straight to polars, pandas, pyarrow, DuckDB and others
|
|
54
|
+
without signalk-cli depending on any of them:
|
|
55
|
+
|
|
56
|
+
Examples:
|
|
57
|
+
```python
|
|
58
|
+
import polars as pl
|
|
59
|
+
df = pl.DataFrame(table)
|
|
60
|
+
import pandas as pd
|
|
61
|
+
df = pd.DataFrame.from_arrow(table) # pandas >= 3.0
|
|
62
|
+
import pyarrow as pa
|
|
63
|
+
t = pa.table(table)
|
|
64
|
+
```
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, columns: dict[str, tuple[list, Any]]) -> None:
|
|
68
|
+
self._columns = {name: values for name, (values, _) in columns.items()}
|
|
69
|
+
self._types = {name: t for name, (_, t) in columns.items()}
|
|
70
|
+
lengths = {len(v) for v in self._columns.values()}
|
|
71
|
+
if len(lengths) > 1:
|
|
72
|
+
raise ValueError(f"Columns have different lengths: {sorted(lengths)}")
|
|
73
|
+
self._num_rows = lengths.pop() if lengths else 0
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_rows(
|
|
77
|
+
cls,
|
|
78
|
+
timestamps: Sequence[str | datetime | None],
|
|
79
|
+
columns: Mapping[str, Sequence[Any]],
|
|
80
|
+
*,
|
|
81
|
+
text_columns: Sequence[str] = (),
|
|
82
|
+
timestamp_name: str = "timestamp",
|
|
83
|
+
) -> "ArrowTable":
|
|
84
|
+
"""Build a table with a UTC timestamp column followed by the given columns.
|
|
85
|
+
|
|
86
|
+
Column types are inferred from the values (see `infer_column`),
|
|
87
|
+
except `text_columns`, which are always strings so an empty result
|
|
88
|
+
keeps the same schema.
|
|
89
|
+
"""
|
|
90
|
+
built: dict[str, tuple[list, Any]] = {
|
|
91
|
+
timestamp_name: ([_epoch_us(t) for t in timestamps], _TIMESTAMP)
|
|
92
|
+
}
|
|
93
|
+
for name, values in columns.items():
|
|
94
|
+
if name in text_columns:
|
|
95
|
+
built[name] = ([as_text(v) for v in values], na.string())
|
|
96
|
+
else:
|
|
97
|
+
built[name] = infer_column(values)
|
|
98
|
+
return cls(built)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def num_rows(self) -> int:
|
|
102
|
+
return self._num_rows
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def column_names(self) -> list[str]:
|
|
106
|
+
return list(self._columns)
|
|
107
|
+
|
|
108
|
+
def __len__(self) -> int:
|
|
109
|
+
return self._num_rows
|
|
110
|
+
|
|
111
|
+
def __repr__(self) -> str:
|
|
112
|
+
cols = ", ".join(f"{n}: {self._types[n].type.name}" for n in self._columns)
|
|
113
|
+
return f"ArrowTable({self._num_rows} rows; {cols})"
|
|
114
|
+
|
|
115
|
+
def to_pydict(self) -> dict[str, list]:
|
|
116
|
+
"""Return the columns as plain Python lists (timestamps as epoch microseconds)."""
|
|
117
|
+
return {name: list(values) for name, values in self._columns.items()}
|
|
118
|
+
|
|
119
|
+
def _batch(self):
|
|
120
|
+
children = [
|
|
121
|
+
na.c_array(values, self._types[name])
|
|
122
|
+
for name, values in self._columns.items()
|
|
123
|
+
]
|
|
124
|
+
schema = na.struct({name: self._types[name] for name in self._columns})
|
|
125
|
+
return na.c_array_from_buffers(
|
|
126
|
+
schema, self._num_rows, [None], children=children
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def __arrow_c_schema__(self):
|
|
130
|
+
return na.c_schema(
|
|
131
|
+
na.struct({name: self._types[name] for name in self._columns})
|
|
132
|
+
).__arrow_c_schema__()
|
|
133
|
+
|
|
134
|
+
def __arrow_c_stream__(self, requested_schema=None):
|
|
135
|
+
return na.ArrayStream(self._batch()).__arrow_c_stream__(requested_schema)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def write_feather(table: ArrowTable, output: str) -> None:
|
|
139
|
+
"""Write a table to a Feather (Arrow IPC file) — needs pyarrow."""
|
|
140
|
+
try:
|
|
141
|
+
import pyarrow as pa
|
|
142
|
+
from pyarrow import feather
|
|
143
|
+
except ImportError:
|
|
144
|
+
raise ImportError(
|
|
145
|
+
"pyarrow is required for Feather output: pip install 'signalk-cli[feather]'"
|
|
146
|
+
) from None
|
|
147
|
+
feather.write_feather(pa.table(table), output)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Click helpers shared by the history and stream CLIs. Not part of the library API."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import io
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from . import net
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("signalk_cli")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _ClickHandler(logging.Handler):
|
|
16
|
+
"""Send library log messages to stderr as plain text, like the CLI's own messages."""
|
|
17
|
+
|
|
18
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
19
|
+
click.echo(self.format(record), err=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def host_option(f):
|
|
23
|
+
return click.option(
|
|
24
|
+
"--host",
|
|
25
|
+
default=None,
|
|
26
|
+
envvar="SIGNALK_HOST",
|
|
27
|
+
help="SignalK server base URL. http:// added if scheme omitted. "
|
|
28
|
+
"Discovered via mDNS if omitted.",
|
|
29
|
+
)(f)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def bare_option(f):
|
|
33
|
+
return click.option(
|
|
34
|
+
"--bare",
|
|
35
|
+
is_flag=True,
|
|
36
|
+
help="Suppress all informational messages, outputting data only.",
|
|
37
|
+
)(f)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@contextlib.contextmanager
|
|
41
|
+
def stderr_ctx(bare: bool) -> Iterator[None]:
|
|
42
|
+
"""Route library log messages to stderr for one command, or discard them all if bare."""
|
|
43
|
+
handler = _ClickHandler()
|
|
44
|
+
previous_level = logger.level
|
|
45
|
+
logger.addHandler(handler)
|
|
46
|
+
logger.setLevel(logging.INFO)
|
|
47
|
+
try:
|
|
48
|
+
with (
|
|
49
|
+
contextlib.redirect_stderr(io.StringIO())
|
|
50
|
+
if bare
|
|
51
|
+
else contextlib.nullcontext()
|
|
52
|
+
):
|
|
53
|
+
yield
|
|
54
|
+
finally:
|
|
55
|
+
logger.removeHandler(handler)
|
|
56
|
+
logger.setLevel(previous_level)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def resolve_host(host: str | None, no_cache: bool = False) -> str:
|
|
60
|
+
"""Return a normalised host URL, discovering via mDNS if none provided."""
|
|
61
|
+
if host:
|
|
62
|
+
return net.normalise_host(host)
|
|
63
|
+
if not no_cache:
|
|
64
|
+
cached = net.get_cached_host()
|
|
65
|
+
if cached:
|
|
66
|
+
click.echo(f"Using cached host: {cached}", err=True)
|
|
67
|
+
return cached
|
|
68
|
+
if net.Zeroconf is None:
|
|
69
|
+
raise click.UsageError(
|
|
70
|
+
"No host specified and mDNS discovery unavailable (zeroconf not "
|
|
71
|
+
"installed). Use --host or set SIGNALK_HOST."
|
|
72
|
+
)
|
|
73
|
+
click.echo("No host specified — searching for SignalK via mDNS...", err=True)
|
|
74
|
+
discovered = net.discover_host()
|
|
75
|
+
if not discovered:
|
|
76
|
+
raise click.UsageError(
|
|
77
|
+
"No SignalK server found via mDNS. Use --host or set SIGNALK_HOST."
|
|
78
|
+
)
|
|
79
|
+
click.echo(f"Discovered: {discovered}", err=True)
|
|
80
|
+
if not no_cache:
|
|
81
|
+
net.save_cached_host(discovered)
|
|
82
|
+
return discovered
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Exceptions raised by the signalk_cli library API."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
|
|
5
|
+
import niquests
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def api_error(exc: niquests.RequestException) -> str:
|
|
9
|
+
"""Return the most informative message from an API error response."""
|
|
10
|
+
resp = getattr(exc, "response", None)
|
|
11
|
+
if resp is not None:
|
|
12
|
+
with contextlib.suppress(Exception):
|
|
13
|
+
body = resp.json()
|
|
14
|
+
return body.get("error") or body.get("message") or str(exc)
|
|
15
|
+
return str(exc)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SignalKError(Exception):
|
|
19
|
+
"""A request to a SignalK server failed.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
status_code: HTTP status of the failed response, or None if the
|
|
23
|
+
server couldn't be reached.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.status_code = status_code
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_request(cls, exc: niquests.RequestException) -> "SignalKError":
|
|
32
|
+
"""Wrap a niquests exception, using the server's error message if it sent one."""
|
|
33
|
+
resp = getattr(exc, "response", None)
|
|
34
|
+
status = getattr(resp, "status_code", None)
|
|
35
|
+
return cls(
|
|
36
|
+
api_error(exc), status_code=status if isinstance(status, int) else None
|
|
37
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""signalk_cli.history — Python client and CLI for the SignalK v2 History API."""
|
|
2
|
+
|
|
3
|
+
from .._arrow import ArrowTable
|
|
4
|
+
from ..errors import SignalKError
|
|
5
|
+
from ._time import TimeRange
|
|
6
|
+
from .api import (
|
|
7
|
+
AGGREGATION_METHODS,
|
|
8
|
+
HISTORY_BASE,
|
|
9
|
+
HistoryClient,
|
|
10
|
+
HistoryResult,
|
|
11
|
+
build_path_specs,
|
|
12
|
+
match_paths,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AGGREGATION_METHODS",
|
|
17
|
+
"HISTORY_BASE",
|
|
18
|
+
"ArrowTable",
|
|
19
|
+
"HistoryClient",
|
|
20
|
+
"HistoryResult",
|
|
21
|
+
"SignalKError",
|
|
22
|
+
"TimeRange",
|
|
23
|
+
"build_path_specs",
|
|
24
|
+
"match_paths",
|
|
25
|
+
]
|