labmon 0.3.0__py3-none-any.whl
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.
- labmon/__init__.py +0 -0
- labmon/adc.py +17 -0
- labmon/admin.py +244 -0
- labmon/calibration.py +997 -0
- labmon/cli/__init__.py +1 -0
- labmon/cli/age.py +60 -0
- labmon/cli/commands/__init__.py +6 -0
- labmon/cli/commands/export.py +256 -0
- labmon/cli/commands/init.py +117 -0
- labmon/cli/commands/mock_sensor.py +143 -0
- labmon/cli/commands/monitor.py +160 -0
- labmon/cli/commands/query.py +114 -0
- labmon/cli/commands/reset_database.py +125 -0
- labmon/cli/commands/sensors.py +177 -0
- labmon/cli/commands/serial_sensor.py +88 -0
- labmon/cli/deprecated.py +42 -0
- labmon/cli/main.py +124 -0
- labmon/cli/monitor.py +292 -0
- labmon/cli/options.py +101 -0
- labmon/cli/quantity.py +245 -0
- labmon/cli/render.py +516 -0
- labmon/cli/roster.py +188 -0
- labmon/cli/runtime.py +127 -0
- labmon/cli/screenshot.py +76 -0
- labmon/cli/selection.py +213 -0
- labmon/cli/tui.py +832 -0
- labmon/config.py +543 -0
- labmon/env.py +124 -0
- labmon/export/__init__.py +16 -0
- labmon/export/formats.py +27 -0
- labmon/export/query.py +450 -0
- labmon/export/table.py +245 -0
- labmon/export/window.py +124 -0
- labmon/export/writers.py +286 -0
- labmon/gate.py +239 -0
- labmon/influx.py +115 -0
- labmon/logs.py +155 -0
- labmon/py.typed +0 -0
- labmon/quantise.py +89 -0
- labmon/sensors/__init__.py +0 -0
- labmon/sensors/constants.py +21 -0
- labmon/sensors/loop.py +183 -0
- labmon/sensors/mock_sensor.py +120 -0
- labmon/sensors/polling.py +226 -0
- labmon/sensors/serial_sensor.py +224 -0
- labmon/sensors/serial_source.py +187 -0
- labmon/version.py +28 -0
- labmon/writer.py +170 -0
- labmon-0.3.0.dist-info/METADATA +113 -0
- labmon-0.3.0.dist-info/RECORD +53 -0
- labmon-0.3.0.dist-info/WHEEL +4 -0
- labmon-0.3.0.dist-info/entry_points.txt +4 -0
- labmon-0.3.0.dist-info/licenses/LICENSE +674 -0
labmon/__init__.py
ADDED
|
File without changes
|
labmon/adc.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""What an ADC reading is worth, before anything physical is involved.
|
|
2
|
+
|
|
3
|
+
These live apart from `labmon.calibration` because they are hardware
|
|
4
|
+
facts rather than calibration logic, and because the command line needs
|
|
5
|
+
them as option defaults — which are evaluated at import. Reaching them
|
|
6
|
+
through `calibration` would make every `labmon --help` and every tab
|
|
7
|
+
completion pay for `pint`, which is a tenth of a second to load and has
|
|
8
|
+
nothing to do with naming a default.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# Bits in one conversion. Twelve suits the parts these sensors are
|
|
12
|
+
# usually built around; a 10-bit or 16-bit board passes its own.
|
|
13
|
+
ADC_RESOLUTION_BITS = 12
|
|
14
|
+
|
|
15
|
+
# Full-scale reference, in volts. 3.3 V is the common rail; a 5 V part
|
|
16
|
+
# passes its own.
|
|
17
|
+
ADC_VREF_VOLTS = 3.3
|
labmon/admin.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Set up an InfluxDB 3 instance: its admin token, and its database.
|
|
2
|
+
|
|
3
|
+
Everything here talks to `/api/v3/configure/...` over plain HTTP rather
|
|
4
|
+
than through `InfluxDBClient3`, for two reasons. The first is ordering:
|
|
5
|
+
the token endpoint is what *issues* the credential every other call
|
|
6
|
+
needs, so it has to work before there is a client to build. The second
|
|
7
|
+
is weight — the client pulls in pyarrow and costs about 0.3s to import,
|
|
8
|
+
which is a lot for a command whose whole job is three requests.
|
|
9
|
+
|
|
10
|
+
Only the operations labmon actually offers are here. InfluxDB 3 Core can
|
|
11
|
+
also create tables, caches and triggers; none of that is labmon's
|
|
12
|
+
business, and wrapping it would mean maintaining a second, worse
|
|
13
|
+
`influxdb3` CLI.
|
|
14
|
+
|
|
15
|
+
What Core *cannot* do is delete rows: its delete granularity is a
|
|
16
|
+
database, a table, a cache, a trigger or a token, and there is no
|
|
17
|
+
`DELETE ... WHERE`. That is why there is no `--since`/`--sensor-id`
|
|
18
|
+
option anywhere in this module, and why resetting means dropping the
|
|
19
|
+
database rather than deleting what is in it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import ssl
|
|
26
|
+
import urllib.error
|
|
27
|
+
import urllib.parse
|
|
28
|
+
import urllib.request
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import cast
|
|
31
|
+
|
|
32
|
+
logger: logging.Logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# Long enough for a server still opening its object store, short enough
|
|
35
|
+
# that a wrong host fails while somebody is still watching.
|
|
36
|
+
TIMEOUT_SECONDS = 30.0
|
|
37
|
+
|
|
38
|
+
# An endpoint, not a credential — S105 matches the name, not the value.
|
|
39
|
+
_ADMIN_TOKEN_PATH = "/api/v3/configure/token/admin" # noqa: S105
|
|
40
|
+
_DATABASE_PATH = "/api/v3/configure/database"
|
|
41
|
+
_QUERY_PATH = "/api/v3/query_sql"
|
|
42
|
+
|
|
43
|
+
# The database holding the server's own catalogue. `system.databases`
|
|
44
|
+
# there is the only place a retention period can be read back: the
|
|
45
|
+
# configure endpoint lists names and nothing else.
|
|
46
|
+
_INTERNAL_DATABASE = "_internal"
|
|
47
|
+
|
|
48
|
+
# Every database the server serves, with its retention. `deleted = false`
|
|
49
|
+
# drops the tombstones a soft delete leaves behind, so a database dropped
|
|
50
|
+
# and recreated under the same name is reported once.
|
|
51
|
+
_RETENTION_QUERY = (
|
|
52
|
+
"SELECT database_name, retention_period_ns FROM system.databases"
|
|
53
|
+
" WHERE deleted = false"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
_NANOSECONDS_PER_DAY = 86_400_000_000_000
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AdminError(Exception):
|
|
60
|
+
"""A request the server refused, in terms the reader can act on."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _tls_context() -> ssl.SSLContext | None:
|
|
64
|
+
"""Trust the stack's own CA when INFLUXDB_TLS_CA names it.
|
|
65
|
+
|
|
66
|
+
The same variable `labmon.influx.get_client` reads, so a deployment
|
|
67
|
+
behind the `tls` profile needs configuring once rather than twice. A
|
|
68
|
+
private root is in no system store, so without this the proxy is
|
|
69
|
+
unreachable rather than merely untrusted.
|
|
70
|
+
"""
|
|
71
|
+
ca = os.environ.get("INFLUXDB_TLS_CA")
|
|
72
|
+
if not ca:
|
|
73
|
+
return None
|
|
74
|
+
if not Path(ca).is_file():
|
|
75
|
+
raise AdminError(
|
|
76
|
+
f"INFLUXDB_TLS_CA points at {ca!r}, which is not a file."
|
|
77
|
+
+ " It should be the CA certificate exported from the server"
|
|
78
|
+
+ " (see scripts/export-ca.sh)."
|
|
79
|
+
)
|
|
80
|
+
return ssl.create_default_context(cafile=ca)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _request(
|
|
84
|
+
method: str,
|
|
85
|
+
host: str,
|
|
86
|
+
path: str,
|
|
87
|
+
*,
|
|
88
|
+
token: str | None = None,
|
|
89
|
+
body: dict[str, object] | None = None,
|
|
90
|
+
query: dict[str, str] | None = None,
|
|
91
|
+
) -> tuple[int, bytes]:
|
|
92
|
+
"""One call to the configure API, returning its status and body.
|
|
93
|
+
|
|
94
|
+
A 4xx comes back as a value rather than an exception because the
|
|
95
|
+
statuses that matter here are ordinary outcomes: 409 means the thing
|
|
96
|
+
already exists, which is what a second `labmon init` should report
|
|
97
|
+
calmly rather than fail on.
|
|
98
|
+
"""
|
|
99
|
+
url = f"{host.rstrip('/')}{path}"
|
|
100
|
+
if query:
|
|
101
|
+
url = f"{url}?{urllib.parse.urlencode(query)}"
|
|
102
|
+
headers = {"Content-Type": "application/json"}
|
|
103
|
+
if token:
|
|
104
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
105
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
106
|
+
# The URL is built from the configured host and this module's own
|
|
107
|
+
# constants, never from a response.
|
|
108
|
+
request = urllib.request.Request(url, data=data, headers=headers, method=method) # noqa: S310
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
# urlopen resolves to `Any`; the handle and its read are pinned.
|
|
112
|
+
opened = urllib.request.urlopen( # noqa: S310 # pyright: ignore[reportAny]
|
|
113
|
+
request, timeout=TIMEOUT_SECONDS, context=_tls_context()
|
|
114
|
+
)
|
|
115
|
+
with opened as response: # pyright: ignore[reportAny]
|
|
116
|
+
return cast(int, response.status), cast(bytes, response.read()) # pyright: ignore[reportAny]
|
|
117
|
+
except urllib.error.HTTPError as error:
|
|
118
|
+
return error.code, error.read()
|
|
119
|
+
except urllib.error.URLError as error:
|
|
120
|
+
raise AdminError(f"cannot reach {host}: {error.reason}") from error
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _refuse(status: int, body: bytes, doing: str) -> None:
|
|
124
|
+
"""Turn an unexpected status into a message naming what failed.
|
|
125
|
+
|
|
126
|
+
The server's own text is quoted: for a malformed retention it says
|
|
127
|
+
`invalid value: string "banana", expected a duration`, which is more
|
|
128
|
+
use than anything this module could invent.
|
|
129
|
+
"""
|
|
130
|
+
detail = body.decode(errors="replace").strip() or f"HTTP {status}"
|
|
131
|
+
raise AdminError(f"{doing}: {detail}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def create_admin_token(host: str) -> str | None:
|
|
135
|
+
"""Issue the instance's admin token, or None if it already has one.
|
|
136
|
+
|
|
137
|
+
Unauthenticated, because this is the call that bootstraps
|
|
138
|
+
authentication. It works exactly once per instance: the second
|
|
139
|
+
attempt is refused with 409 and the original token is not recoverable
|
|
140
|
+
from the server, which is why `labmon init` writes it to `.env`
|
|
141
|
+
rather than printing it and hoping.
|
|
142
|
+
"""
|
|
143
|
+
status, body = _request("POST", host, _ADMIN_TOKEN_PATH)
|
|
144
|
+
if status == 409:
|
|
145
|
+
return None
|
|
146
|
+
if status not in (200, 201):
|
|
147
|
+
_refuse(status, body, "could not create an admin token")
|
|
148
|
+
payload = cast(dict[str, object], json.loads(body.decode()))
|
|
149
|
+
token = payload.get("token")
|
|
150
|
+
if not isinstance(token, str) or not token:
|
|
151
|
+
raise AdminError("the server issued a token with no value in it")
|
|
152
|
+
return token
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def create_database(host: str, token: str, name: str, retention: str | None) -> bool:
|
|
156
|
+
"""Create `name`, returning False if it was already there.
|
|
157
|
+
|
|
158
|
+
`retention` is a duration the server parses — `1y`, `30d`, `24h` —
|
|
159
|
+
or None for unlimited, which is also what a database gets when a
|
|
160
|
+
write brings it into existence on its own.
|
|
161
|
+
"""
|
|
162
|
+
body: dict[str, object] = {"db": name, "retention_period": retention}
|
|
163
|
+
status, response = _request("POST", host, _DATABASE_PATH, token=token, body=body)
|
|
164
|
+
if status == 409:
|
|
165
|
+
return False
|
|
166
|
+
if status not in (200, 201):
|
|
167
|
+
_refuse(status, response, f"could not create the database {name!r}")
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def set_retention(host: str, token: str, name: str, retention: str | None) -> None:
|
|
172
|
+
"""Change how long `name` keeps readings, on a database that exists."""
|
|
173
|
+
body: dict[str, object] = {"db": name, "retention_period": retention}
|
|
174
|
+
status, response = _request("PUT", host, _DATABASE_PATH, token=token, body=body)
|
|
175
|
+
if status not in (200, 201):
|
|
176
|
+
_refuse(status, response, f"could not set the retention on {name!r}")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def delete_database(host: str, token: str, name: str, *, hard: bool = False) -> None:
|
|
180
|
+
"""Delete `name` and everything in it.
|
|
181
|
+
|
|
182
|
+
Soft by default, which is the server's own default: the data stops
|
|
183
|
+
being queryable at once, and what is on disk is renamed to
|
|
184
|
+
`<name>-<timestamp>` and reclaimed later. The original name is free
|
|
185
|
+
immediately, so a database of the same name can be created straight
|
|
186
|
+
after — which is what lets a reset be a delete followed by a create
|
|
187
|
+
rather than two steps with a wait between them.
|
|
188
|
+
|
|
189
|
+
`hard` asks for that space back now rather than on the server's own
|
|
190
|
+
schedule. The renamed copy is a real safety net — an accidental reset
|
|
191
|
+
is recoverable from it until the server clears it — so this is a
|
|
192
|
+
deliberate request rather than the default, and it is the answer for
|
|
193
|
+
a disk that is actually full.
|
|
194
|
+
|
|
195
|
+
Either way the catalogue keeps a row for the deletion, which
|
|
196
|
+
`influxdb3 show databases` lists; `hard` shortens how long the data
|
|
197
|
+
behind it lives, not whether the record of it does.
|
|
198
|
+
"""
|
|
199
|
+
query = {"db": name}
|
|
200
|
+
if hard:
|
|
201
|
+
query["hard_delete_at"] = "now"
|
|
202
|
+
status, response = _request(
|
|
203
|
+
"DELETE", host, _DATABASE_PATH, token=token, query=query
|
|
204
|
+
)
|
|
205
|
+
if status not in (200, 204):
|
|
206
|
+
_refuse(status, response, f"could not delete the database {name!r}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def read_retention(host: str, token: str, name: str) -> str | None:
|
|
210
|
+
"""The retention `name` currently keeps, as a duration, or None.
|
|
211
|
+
|
|
212
|
+
Read so a reset can put back what was there. Without it, resetting a
|
|
213
|
+
database created with `--retention 1y` would quietly return it to
|
|
214
|
+
keeping everything for ever, and nothing would say so until the disk
|
|
215
|
+
filled.
|
|
216
|
+
|
|
217
|
+
Reported in whole days, which every value the server accepts from
|
|
218
|
+
labmon resolves to. `1y` is stored as 365.25 days and comes back as
|
|
219
|
+
`365d`; the quarter day is InfluxDB's own rounding of a year, not
|
|
220
|
+
something worth carrying through a reset.
|
|
221
|
+
"""
|
|
222
|
+
query = {"db": _INTERNAL_DATABASE, "q": _RETENTION_QUERY, "format": "json"}
|
|
223
|
+
status, body = _request("GET", host, _QUERY_PATH, token=token, query=query)
|
|
224
|
+
if status != 200:
|
|
225
|
+
_refuse(status, body, "could not read the current retention")
|
|
226
|
+
rows = cast(list[dict[str, object]], json.loads(body.decode()))
|
|
227
|
+
for row in rows:
|
|
228
|
+
if row.get("database_name") != name:
|
|
229
|
+
continue
|
|
230
|
+
nanoseconds = row.get("retention_period_ns")
|
|
231
|
+
if not isinstance(nanoseconds, int):
|
|
232
|
+
return None
|
|
233
|
+
return f"{max(1, round(nanoseconds / _NANOSECONDS_PER_DAY))}d"
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def database_exists(host: str, token: str, name: str) -> bool:
|
|
238
|
+
"""Whether `name` is a database the server currently serves."""
|
|
239
|
+
query = {"db": _INTERNAL_DATABASE, "q": _RETENTION_QUERY, "format": "json"}
|
|
240
|
+
status, body = _request("GET", host, _QUERY_PATH, token=token, query=query)
|
|
241
|
+
if status != 200:
|
|
242
|
+
_refuse(status, body, "could not list the databases")
|
|
243
|
+
rows = cast(list[dict[str, object]], json.loads(body.decode()))
|
|
244
|
+
return any(row.get("database_name") == name for row in rows)
|