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.
Files changed (53) hide show
  1. labmon/__init__.py +0 -0
  2. labmon/adc.py +17 -0
  3. labmon/admin.py +244 -0
  4. labmon/calibration.py +997 -0
  5. labmon/cli/__init__.py +1 -0
  6. labmon/cli/age.py +60 -0
  7. labmon/cli/commands/__init__.py +6 -0
  8. labmon/cli/commands/export.py +256 -0
  9. labmon/cli/commands/init.py +117 -0
  10. labmon/cli/commands/mock_sensor.py +143 -0
  11. labmon/cli/commands/monitor.py +160 -0
  12. labmon/cli/commands/query.py +114 -0
  13. labmon/cli/commands/reset_database.py +125 -0
  14. labmon/cli/commands/sensors.py +177 -0
  15. labmon/cli/commands/serial_sensor.py +88 -0
  16. labmon/cli/deprecated.py +42 -0
  17. labmon/cli/main.py +124 -0
  18. labmon/cli/monitor.py +292 -0
  19. labmon/cli/options.py +101 -0
  20. labmon/cli/quantity.py +245 -0
  21. labmon/cli/render.py +516 -0
  22. labmon/cli/roster.py +188 -0
  23. labmon/cli/runtime.py +127 -0
  24. labmon/cli/screenshot.py +76 -0
  25. labmon/cli/selection.py +213 -0
  26. labmon/cli/tui.py +832 -0
  27. labmon/config.py +543 -0
  28. labmon/env.py +124 -0
  29. labmon/export/__init__.py +16 -0
  30. labmon/export/formats.py +27 -0
  31. labmon/export/query.py +450 -0
  32. labmon/export/table.py +245 -0
  33. labmon/export/window.py +124 -0
  34. labmon/export/writers.py +286 -0
  35. labmon/gate.py +239 -0
  36. labmon/influx.py +115 -0
  37. labmon/logs.py +155 -0
  38. labmon/py.typed +0 -0
  39. labmon/quantise.py +89 -0
  40. labmon/sensors/__init__.py +0 -0
  41. labmon/sensors/constants.py +21 -0
  42. labmon/sensors/loop.py +183 -0
  43. labmon/sensors/mock_sensor.py +120 -0
  44. labmon/sensors/polling.py +226 -0
  45. labmon/sensors/serial_sensor.py +224 -0
  46. labmon/sensors/serial_source.py +187 -0
  47. labmon/version.py +28 -0
  48. labmon/writer.py +170 -0
  49. labmon-0.3.0.dist-info/METADATA +113 -0
  50. labmon-0.3.0.dist-info/RECORD +53 -0
  51. labmon-0.3.0.dist-info/WHEEL +4 -0
  52. labmon-0.3.0.dist-info/entry_points.txt +4 -0
  53. 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)