devicectl-core 0.1.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.
- devicectl/__init__.py +18 -0
- devicectl/cli/__init__.py +1 -0
- devicectl/cli/command.py +95 -0
- devicectl/cli/exits.py +32 -0
- devicectl/cli/fanout.py +142 -0
- devicectl/cli/main.py +69 -0
- devicectl/cli/output.py +299 -0
- devicectl/cli/parser.py +80 -0
- devicectl/cli/report.py +86 -0
- devicectl/cli/target.py +26 -0
- devicectl/clock.py +57 -0
- devicectl/devtools/__init__.py +6 -0
- devicectl/devtools/frontlint.py +935 -0
- devicectl/devtools/htmcheck.py +396 -0
- devicectl/devtools/rendercheck.py +384 -0
- devicectl/doctor.py +112 -0
- devicectl/errors.py +68 -0
- devicectl/fields.py +564 -0
- devicectl/meta.py +64 -0
- devicectl/paths.py +40 -0
- devicectl/progress.py +77 -0
- devicectl/report.py +67 -0
- devicectl/testing.py +199 -0
- devicectl/trace.py +333 -0
- devicectl/web/__init__.py +1 -0
- devicectl/web/agents.py +94 -0
- devicectl/web/events.py +171 -0
- devicectl/web/http.py +243 -0
- devicectl/web/progress.py +101 -0
- devicectl/web/server.py +1013 -0
- devicectl/web/static/core.css +3034 -0
- devicectl/web/static/js/api.js +198 -0
- devicectl/web/static/js/band.js +640 -0
- devicectl/web/static/js/chart.js +400 -0
- devicectl/web/static/js/drafts.js +312 -0
- devicectl/web/static/js/notify.js +272 -0
- devicectl/web/static/js/panels.js +432 -0
- devicectl/web/static/js/shell.js +672 -0
- devicectl/web/static/js/trace.js +133 -0
- devicectl/web/static/js/ui.js +1139 -0
- devicectl/web/static/vendor/preact-htm.module.js +27 -0
- devicectl/web/worker.py +697 -0
- devicectl_core-0.1.0.dist-info/METADATA +131 -0
- devicectl_core-0.1.0.dist-info/RECORD +47 -0
- devicectl_core-0.1.0.dist-info/WHEEL +4 -0
- devicectl_core-0.1.0.dist-info/licenses/LICENSE +287 -0
- devicectl_core-0.1.0.dist-info/licenses/NOTICE +13 -0
devicectl/fields.py
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
"""One description per setting, for every place a setting has to be described.
|
|
2
|
+
|
|
3
|
+
A configuration group -- a charger's load balancing, a battery's protection
|
|
4
|
+
thresholds -- is a list of fields, and a program built the obvious way ends up
|
|
5
|
+
writing that list five times: once to decode what the device answered, once to
|
|
6
|
+
print it, once to serialise it for a browser, once as ``add_argument`` calls,
|
|
7
|
+
and once inside the function that writes it back. The five copies drift, and
|
|
8
|
+
they drift quietly: a field gains a bound in the validator that the slider in
|
|
9
|
+
the browser does not know about, or a JSON key is renamed and the terminal
|
|
10
|
+
keeps the old label.
|
|
11
|
+
|
|
12
|
+
A :class:`FieldSpec` is that description written once. It carries what the
|
|
13
|
+
field *is* (a number, a flag, one of an enumeration), where it lives on the
|
|
14
|
+
device, what it may be set to, and what each of the three audiences calls it:
|
|
15
|
+
a label for a terminal, a key for a document, a flag for a command line. The
|
|
16
|
+
functions below then do each of the five jobs by walking the same tuple.
|
|
17
|
+
|
|
18
|
+
The device-facing halves -- :attr:`FieldSpec.address` and
|
|
19
|
+
:attr:`FieldSpec.wire` -- are deliberately untyped. This module never
|
|
20
|
+
dereferences either: it hands them back to the caller, who knows whether an
|
|
21
|
+
address is an object-dictionary ``(index, sub)`` pair or a Modbus register,
|
|
22
|
+
and whether a wire type is an EDS code or a struct format. What is shared is
|
|
23
|
+
the bookkeeping around them, not the protocol.
|
|
24
|
+
|
|
25
|
+
Nothing here is required: a group with an irregularity -- a bit field written
|
|
26
|
+
as two booleans, a value that is really two registers, a row that names its
|
|
27
|
+
neighbour -- keeps that part by hand and describes the rest. A table that has
|
|
28
|
+
to grow a flag for every exception is not paying for itself.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import math
|
|
34
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from devicectl.errors import DeviceError
|
|
39
|
+
|
|
40
|
+
# What a field's value *is*, which decides how it is decoded, checked and
|
|
41
|
+
# shown. A device's own type codes are finer than this on purpose: whether a
|
|
42
|
+
# count arrives as a byte or a word changes how it is encoded and nothing
|
|
43
|
+
# else, and the encoding is the caller's business.
|
|
44
|
+
NUMBER = "number" # a real quantity: amps, volts, degrees
|
|
45
|
+
INTEGER = "integer" # a whole number: seconds, a percentage, a count
|
|
46
|
+
TEXT = "text" # a string
|
|
47
|
+
FLAG = "flag" # on or off
|
|
48
|
+
ENUM = "enum" # one of a named set of codes
|
|
49
|
+
|
|
50
|
+
READ_ONLY = "r"
|
|
51
|
+
READ_WRITE = "rw"
|
|
52
|
+
|
|
53
|
+
ON_OFF = ("on", "off")
|
|
54
|
+
|
|
55
|
+
# For a field that is an enumeration on the wire and a switch to a person.
|
|
56
|
+
ON_OFF_CODES = {"on": 1, "off": 0}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class FieldError(DeviceError, ValueError):
|
|
60
|
+
"""A value a field could not sensibly be given.
|
|
61
|
+
|
|
62
|
+
A :class:`~devicectl.errors.DeviceError`, so the command line prints it as
|
|
63
|
+
one line and the web server answers 400, with nothing to add at either end;
|
|
64
|
+
and a :class:`ValueError`, because that is what it is. A program whose
|
|
65
|
+
group already has an error class of its own derives it from both -- ``class
|
|
66
|
+
LoadBalancingError(AlfenError, FieldError)`` -- so that code catching
|
|
67
|
+
either keeps catching it.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class FieldSpec:
|
|
73
|
+
"""One setting, described once for all five of its audiences.
|
|
74
|
+
|
|
75
|
+
Only :attr:`name` is required. A field with no :attr:`label` is not
|
|
76
|
+
printed, one with no :attr:`json` is not serialised, one with no
|
|
77
|
+
:attr:`flag` is not on the command line, and one whose :attr:`access` is
|
|
78
|
+
``r`` is never written -- which is how a table holds a group's read-only
|
|
79
|
+
neighbours (a negotiated heartbeat, an address the charger was handed)
|
|
80
|
+
without pretending they can be set.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
name: str
|
|
84
|
+
"""The attribute on the state object, and the keyword :func:`values` uses."""
|
|
85
|
+
|
|
86
|
+
kind: str = TEXT
|
|
87
|
+
"""One of :data:`NUMBER`, :data:`INTEGER`, :data:`TEXT`, :data:`FLAG`, :data:`ENUM`."""
|
|
88
|
+
|
|
89
|
+
address: Any = None
|
|
90
|
+
"""Where the field lives on the device. Opaque here; the caller reads it."""
|
|
91
|
+
|
|
92
|
+
wire: Any = None
|
|
93
|
+
"""How a write is encoded. Opaque here too."""
|
|
94
|
+
|
|
95
|
+
label: str | None = None
|
|
96
|
+
"""What a terminal calls it, or None to leave it out of the rows."""
|
|
97
|
+
|
|
98
|
+
json: str | None = None
|
|
99
|
+
"""What a document calls it, or None to leave it out."""
|
|
100
|
+
|
|
101
|
+
flag: str | None = None
|
|
102
|
+
"""Its command-line flag (``--safe-current``), or None for none."""
|
|
103
|
+
|
|
104
|
+
unit: str = ""
|
|
105
|
+
"""The unit a printed value carries: ``A``, ``s``, ``%``, ``W``."""
|
|
106
|
+
|
|
107
|
+
minimum: float | None = None
|
|
108
|
+
maximum: float | None = None
|
|
109
|
+
"""The range a value has to be inside. For :data:`TEXT`, the length."""
|
|
110
|
+
|
|
111
|
+
decimals: int | None = None
|
|
112
|
+
"""How many decimal places a reading is shown to, or None for as many as it has.
|
|
113
|
+
|
|
114
|
+
A device that measures to the millivolt and one that measures to the volt
|
|
115
|
+
both answer with a number; only the field knows which of them this is, and
|
|
116
|
+
a row that prints 3.2999999 has lost that.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
default: Any = None
|
|
120
|
+
"""What the device leaves the factory with, when the field says so.
|
|
121
|
+
|
|
122
|
+
Shown beside the value rather than written: a settings table that can say
|
|
123
|
+
"you have 2.8 V here, the default is 3.0 V" answers the question somebody
|
|
124
|
+
is actually asking before they change anything.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
options: Mapping[Any, str] | Sequence[Any] | None = None
|
|
128
|
+
"""The values allowed: a code-to-label mapping, or a plain sequence.
|
|
129
|
+
|
|
130
|
+
A code is whatever the device's own description calls it -- an integer in
|
|
131
|
+
a catalog written by hand, a decimal string in one read out of a vendor's
|
|
132
|
+
datasource -- so the key type is the caller's, like :attr:`address`.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
words: tuple[str, str] = ("enabled", "disabled")
|
|
136
|
+
"""What a :data:`FLAG` reads as when it is on and when it is off."""
|
|
137
|
+
|
|
138
|
+
help: str | None = None
|
|
139
|
+
"""The command-line help, without the enumeration: that is added."""
|
|
140
|
+
|
|
141
|
+
metavar: str | None = None
|
|
142
|
+
"""The command line's placeholder (``AMPS``, ``S``, ``URL``)."""
|
|
143
|
+
|
|
144
|
+
what: str | None = None
|
|
145
|
+
"""How a complaint names the field: "the safe current" rather than "Safe current"."""
|
|
146
|
+
|
|
147
|
+
access: str = READ_WRITE
|
|
148
|
+
|
|
149
|
+
aliases: Mapping[str, Any] | None = None
|
|
150
|
+
"""Words the command line takes instead of raw values, and what each means.
|
|
151
|
+
|
|
152
|
+
For a setting whose codes nobody should have to remember -- ``--mode rfid``
|
|
153
|
+
rather than ``--mode 2`` -- and for one that is a code on the wire but a
|
|
154
|
+
switch to a person, which is :data:`ON_OFF_CODES`. A :data:`FLAG` gets
|
|
155
|
+
``on``/``off`` without asking.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
render: Callable[[Any, Any], str | None] | None = None
|
|
159
|
+
"""Print this one by hand, given ``(value, state)``. For composite rows."""
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def writable(self) -> bool:
|
|
163
|
+
"""Whether this field may be written at all."""
|
|
164
|
+
return self.access == READ_WRITE
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def subject(self) -> str:
|
|
168
|
+
"""How a complaint should name this field."""
|
|
169
|
+
return self.what or self.label or self.name
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def labels(self) -> Mapping[Any, str] | None:
|
|
173
|
+
"""The code-to-label table, when the options carry one."""
|
|
174
|
+
return self.options if isinstance(self.options, Mapping) else None
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def domain(self) -> tuple[Any, ...] | None:
|
|
178
|
+
"""Every value this field allows, or None when it is unrestricted."""
|
|
179
|
+
return None if self.options is None else tuple(self.options)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
Specs = Sequence[FieldSpec]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def by_name(specs: Specs) -> dict[str, FieldSpec]:
|
|
186
|
+
"""Index a table by field name."""
|
|
187
|
+
return {spec.name: spec for spec in specs}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def by_flag(specs: Specs) -> dict[str, FieldSpec]:
|
|
191
|
+
"""Index the settable fields by the command-line flag that names them."""
|
|
192
|
+
return {spec.flag: spec for spec in specs if spec.flag and spec.writable}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# --- reading ----------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def decode(spec: FieldSpec, raw: Any) -> Any:
|
|
199
|
+
"""Turn what the device answered into the value the state object holds.
|
|
200
|
+
|
|
201
|
+
None for anything the device did not say, or said in a shape the field
|
|
202
|
+
cannot be: a station that answers an empty string for a current has not
|
|
203
|
+
reported 0 A, it has declined to answer, and the difference is the whole
|
|
204
|
+
point of the em dash a dashboard draws.
|
|
205
|
+
"""
|
|
206
|
+
if raw is None or raw == "":
|
|
207
|
+
return None
|
|
208
|
+
if spec.kind == TEXT:
|
|
209
|
+
return str(raw)
|
|
210
|
+
try:
|
|
211
|
+
number = float(raw)
|
|
212
|
+
except (TypeError, ValueError):
|
|
213
|
+
return None
|
|
214
|
+
if spec.kind == NUMBER:
|
|
215
|
+
return number
|
|
216
|
+
if spec.kind == FLAG:
|
|
217
|
+
return bool(int(number))
|
|
218
|
+
return int(number)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def harvest(specs: Specs, read: Callable[[Any], Any]) -> dict[str, Any]:
|
|
222
|
+
"""Decode every addressed field, given a way to look one up by address."""
|
|
223
|
+
return {
|
|
224
|
+
spec.name: decode(spec, read(spec.address))
|
|
225
|
+
for spec in specs
|
|
226
|
+
if spec.address is not None
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# --- checking ---------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _enumerated(spec: FieldSpec) -> str:
|
|
234
|
+
"""Name every value a field allows, the way a complaint should list them."""
|
|
235
|
+
labels = spec.labels
|
|
236
|
+
if labels is None:
|
|
237
|
+
return "one of " + ", ".join(str(value) for value in spec.domain or ())
|
|
238
|
+
return ", ".join(f"{value} ({label})" for value, label in sorted(labels.items()))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _ranged(spec: FieldSpec, value: float) -> None:
|
|
242
|
+
"""Raise unless ``value`` is inside the field's range."""
|
|
243
|
+
low, high = spec.minimum, spec.maximum
|
|
244
|
+
if low is not None and high is None and value < low:
|
|
245
|
+
if low == 0:
|
|
246
|
+
raise FieldError(f"{spec.subject} cannot be negative")
|
|
247
|
+
raise FieldError(f"{spec.subject} cannot be below {low:g}{_suffix(spec)}")
|
|
248
|
+
if high is not None and low is None and value > high:
|
|
249
|
+
raise FieldError(f"{spec.subject} cannot be above {high:g}{_suffix(spec)}")
|
|
250
|
+
if low is not None and high is not None and not low <= value <= high:
|
|
251
|
+
raise FieldError(
|
|
252
|
+
f"{spec.subject} must be between {low:g} and {high:g}{_suffix(spec)}"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _suffix(spec: FieldSpec) -> str:
|
|
257
|
+
"""Return the unit as it trails a number in a sentence."""
|
|
258
|
+
if not spec.unit:
|
|
259
|
+
return ""
|
|
260
|
+
return spec.unit if spec.unit == "%" else f" {spec.unit}"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _coerce_text(spec: FieldSpec, value: Any) -> str:
|
|
264
|
+
"""Check a string against the field's enumeration and its two lengths."""
|
|
265
|
+
text = str(value)
|
|
266
|
+
if spec.domain is not None and text not in spec.domain:
|
|
267
|
+
raise FieldError(f"{spec.subject} is {_enumerated(spec)}")
|
|
268
|
+
if spec.maximum is not None and len(text) > int(spec.maximum):
|
|
269
|
+
raise FieldError(f"{spec.subject} is at most {int(spec.maximum)} characters")
|
|
270
|
+
if spec.minimum is not None and len(text) < int(spec.minimum):
|
|
271
|
+
raise FieldError(f"{spec.subject} is at least {int(spec.minimum)} characters")
|
|
272
|
+
return text
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _coerce_number(spec: FieldSpec, value: Any) -> float:
|
|
276
|
+
"""Read a number, refusing the two ways one can fail to be one."""
|
|
277
|
+
try:
|
|
278
|
+
number = float(value)
|
|
279
|
+
except (TypeError, ValueError):
|
|
280
|
+
raise FieldError(f"{spec.subject} must be a number") from None
|
|
281
|
+
if math.isnan(number):
|
|
282
|
+
raise FieldError(f"{spec.subject} must be a number")
|
|
283
|
+
return number
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _in_domain(spec: FieldSpec, code: int) -> int:
|
|
287
|
+
"""Check a whole number against the field's enumeration."""
|
|
288
|
+
if spec.domain is not None and code not in spec.domain:
|
|
289
|
+
raise FieldError(f"{spec.subject} is {_enumerated(spec)}")
|
|
290
|
+
return code
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def coerce(spec: FieldSpec, value: Any) -> Any:
|
|
294
|
+
"""Return ``value`` as the device should be given it, or raise.
|
|
295
|
+
|
|
296
|
+
Every refusal a device would make silently is made here instead, in the
|
|
297
|
+
field's own words: out of range, outside the enumeration, too long.
|
|
298
|
+
"""
|
|
299
|
+
if spec.kind == FLAG:
|
|
300
|
+
return int(bool(value))
|
|
301
|
+
if spec.kind == TEXT:
|
|
302
|
+
return _coerce_text(spec, value)
|
|
303
|
+
number = _coerce_number(spec, value)
|
|
304
|
+
if spec.kind == ENUM:
|
|
305
|
+
return _in_domain(spec, int(number))
|
|
306
|
+
if spec.kind == INTEGER:
|
|
307
|
+
whole = _in_domain(spec, int(number))
|
|
308
|
+
_ranged(spec, whole)
|
|
309
|
+
return whole
|
|
310
|
+
_ranged(spec, number)
|
|
311
|
+
return number
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def values(specs: Specs, given: Mapping[str, Any]) -> dict[str, Any]:
|
|
315
|
+
"""Check every named setting, dropping the ones that were left out.
|
|
316
|
+
|
|
317
|
+
``given`` is whatever the caller was handed -- a namespace turned into a
|
|
318
|
+
dict, a JSON body already mapped onto field names -- with None meaning
|
|
319
|
+
"not named", which is how one flag out of twenty gets set without the
|
|
320
|
+
other nineteen being rewritten to what they already were.
|
|
321
|
+
"""
|
|
322
|
+
table = by_name(specs)
|
|
323
|
+
out: dict[str, Any] = {}
|
|
324
|
+
for name, value in given.items():
|
|
325
|
+
if value is None:
|
|
326
|
+
continue
|
|
327
|
+
spec = table.get(name)
|
|
328
|
+
if spec is None:
|
|
329
|
+
raise FieldError(f"there is no {name!r} setting here")
|
|
330
|
+
if not spec.writable:
|
|
331
|
+
raise FieldError(f"{spec.subject} is read-only")
|
|
332
|
+
out[name] = coerce(spec, value)
|
|
333
|
+
return out
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def writes(
|
|
337
|
+
specs: Specs,
|
|
338
|
+
checked: Mapping[str, Any],
|
|
339
|
+
*,
|
|
340
|
+
wire: Callable[[FieldSpec], Any] | None = None,
|
|
341
|
+
) -> dict[Any, tuple[Any, Any]]:
|
|
342
|
+
"""Turn checked values into ``address -> (value, wire type)`` pairs.
|
|
343
|
+
|
|
344
|
+
``wire`` overrides where the encoding comes from, for a device that
|
|
345
|
+
reports each field's type itself rather than declaring it in a catalog.
|
|
346
|
+
"""
|
|
347
|
+
table = by_name(specs)
|
|
348
|
+
out: dict[Any, tuple[Any, Any]] = {}
|
|
349
|
+
for name, value in checked.items():
|
|
350
|
+
spec = table[name]
|
|
351
|
+
if spec.address is None:
|
|
352
|
+
raise FieldError(f"{spec.subject} has no address to write to")
|
|
353
|
+
out[spec.address] = (value, spec.wire if wire is None else wire(spec))
|
|
354
|
+
return out
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# --- showing ----------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def amount(spec: FieldSpec, value: float) -> str:
|
|
361
|
+
"""Render a number with its unit, the way a row carries it."""
|
|
362
|
+
text = f"{value:g}" if isinstance(value, float) else f"{value}"
|
|
363
|
+
return f"{text}{_suffix(spec)}"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def display(spec: FieldSpec, value: Any, state: Any = None) -> str | None:
|
|
367
|
+
"""Render one value for a terminal, or None when there is nothing to show."""
|
|
368
|
+
if spec.render is not None:
|
|
369
|
+
return spec.render(value, state)
|
|
370
|
+
if value is None or value == "":
|
|
371
|
+
return None
|
|
372
|
+
if spec.kind == FLAG:
|
|
373
|
+
return spec.words[0] if value else spec.words[1]
|
|
374
|
+
if spec.kind == TEXT:
|
|
375
|
+
return str(value)
|
|
376
|
+
labels = spec.labels
|
|
377
|
+
if labels is not None:
|
|
378
|
+
code = int(value)
|
|
379
|
+
return labels.get(code, f"unknown ({code})")
|
|
380
|
+
return amount(spec, value)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def rows(specs: Specs, state: Any) -> list[tuple[str, str]]:
|
|
384
|
+
"""Render the labelled fields as label/value pairs, skipping what is absent."""
|
|
385
|
+
out: list[tuple[str, str]] = []
|
|
386
|
+
for spec in specs:
|
|
387
|
+
if spec.label is None:
|
|
388
|
+
continue
|
|
389
|
+
shown = display(spec, getattr(state, spec.name, None), state)
|
|
390
|
+
if shown is not None:
|
|
391
|
+
out.append((spec.label, shown))
|
|
392
|
+
return out
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def document(specs: Specs, state: Any) -> dict[str, Any]:
|
|
396
|
+
"""Render the serialised fields as a document, absences included.
|
|
397
|
+
|
|
398
|
+
A field the device did not answer stays in as ``null`` rather than
|
|
399
|
+
dropping out, so a page can draw a steady row of fields with an em dash
|
|
400
|
+
where the answer is missing instead of changing shape between polls.
|
|
401
|
+
"""
|
|
402
|
+
return {
|
|
403
|
+
spec.json: getattr(state, spec.name, None)
|
|
404
|
+
for spec in specs
|
|
405
|
+
if spec.json is not None
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def option_tables(specs: Specs) -> dict[str, Any]:
|
|
410
|
+
"""Render every field's allowed values, keyed by its JSON name.
|
|
411
|
+
|
|
412
|
+
A mapping becomes an object keyed by the code as a string, because JSON
|
|
413
|
+
has no integer keys; a plain sequence stays a list.
|
|
414
|
+
"""
|
|
415
|
+
out: dict[str, Any] = {}
|
|
416
|
+
for spec in specs:
|
|
417
|
+
if spec.json is None or spec.options is None:
|
|
418
|
+
continue
|
|
419
|
+
labels = spec.labels
|
|
420
|
+
out[spec.json] = (
|
|
421
|
+
{str(code): label for code, label in labels.items()}
|
|
422
|
+
if labels is not None
|
|
423
|
+
else list(spec.options) # type: ignore[arg-type]
|
|
424
|
+
)
|
|
425
|
+
return out
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def bounds(specs: Specs) -> dict[str, dict[str, float]]:
|
|
429
|
+
"""Render every field's range, keyed by its JSON name."""
|
|
430
|
+
out: dict[str, dict[str, float]] = {}
|
|
431
|
+
for spec in specs:
|
|
432
|
+
if spec.json is None or spec.kind not in (NUMBER, INTEGER):
|
|
433
|
+
continue
|
|
434
|
+
limits = {
|
|
435
|
+
side: value
|
|
436
|
+
for side, value in (("min", spec.minimum), ("max", spec.maximum))
|
|
437
|
+
if value is not None
|
|
438
|
+
}
|
|
439
|
+
if limits:
|
|
440
|
+
out[spec.json] = limits
|
|
441
|
+
return out
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# --- the command line -------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def help_text(spec: FieldSpec) -> str | None:
|
|
448
|
+
"""Return a flag's help, with the enumeration appended when it takes a code."""
|
|
449
|
+
said = spec.help
|
|
450
|
+
if spec.labels is None or spec.aliases is not None:
|
|
451
|
+
return said
|
|
452
|
+
listed = _enumerated(spec)
|
|
453
|
+
return f"{said}: {listed}" if said else listed
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def argument(spec: FieldSpec) -> dict[str, Any]:
|
|
457
|
+
"""Return the ``add_argument`` keywords one field asks for."""
|
|
458
|
+
kwargs: dict[str, Any] = {"dest": spec.name, "help": help_text(spec)}
|
|
459
|
+
if spec.aliases is not None:
|
|
460
|
+
kwargs["choices"] = tuple(spec.aliases)
|
|
461
|
+
return kwargs
|
|
462
|
+
if spec.kind == FLAG:
|
|
463
|
+
kwargs["choices"] = ON_OFF
|
|
464
|
+
return kwargs
|
|
465
|
+
if spec.kind == TEXT:
|
|
466
|
+
if spec.domain is not None:
|
|
467
|
+
kwargs["choices"] = tuple(spec.domain)
|
|
468
|
+
else:
|
|
469
|
+
kwargs["metavar"] = spec.metavar
|
|
470
|
+
return kwargs
|
|
471
|
+
kwargs["type"] = float if spec.kind == NUMBER else int
|
|
472
|
+
if spec.kind != ENUM and spec.domain is not None:
|
|
473
|
+
kwargs["choices"] = tuple(spec.domain)
|
|
474
|
+
else:
|
|
475
|
+
kwargs["metavar"] = spec.metavar or "N"
|
|
476
|
+
return kwargs
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def add_arguments(specs: Specs, parser: Any) -> None:
|
|
480
|
+
"""Add a flag for every settable field that has one."""
|
|
481
|
+
for spec in specs:
|
|
482
|
+
if spec.flag is None or not spec.writable:
|
|
483
|
+
continue
|
|
484
|
+
parser.add_argument(spec.flag, **argument(spec))
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def from_namespace(specs: Specs, args: Any) -> dict[str, Any]:
|
|
488
|
+
"""Collect the settings a parsed command line named, and check them.
|
|
489
|
+
|
|
490
|
+
A word the flag takes comes back as the value it stands for, so a field
|
|
491
|
+
that is a code on the wire and a switch -- or a name -- on the command line
|
|
492
|
+
is spelled once.
|
|
493
|
+
"""
|
|
494
|
+
given: dict[str, Any] = {}
|
|
495
|
+
for spec in specs:
|
|
496
|
+
if spec.flag is None or not spec.writable:
|
|
497
|
+
continue
|
|
498
|
+
value = getattr(args, spec.name, None)
|
|
499
|
+
if value is None:
|
|
500
|
+
continue
|
|
501
|
+
if spec.aliases is not None:
|
|
502
|
+
given[spec.name] = spec.aliases[value]
|
|
503
|
+
elif spec.kind == FLAG:
|
|
504
|
+
given[spec.name] = value == ON_OFF[0]
|
|
505
|
+
else:
|
|
506
|
+
given[spec.name] = value
|
|
507
|
+
return values(specs, given)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def from_document(specs: Specs, doc: Mapping[str, Any]) -> dict[str, Any]:
|
|
511
|
+
"""Collect the settings a JSON body named, by their JSON keys, and check them.
|
|
512
|
+
|
|
513
|
+
A key the group does not define is refused rather than ignored: a page
|
|
514
|
+
sending ``safeCurrent`` where the field is ``safeCurrentA`` should be told
|
|
515
|
+
so, not left wondering why nothing changed.
|
|
516
|
+
"""
|
|
517
|
+
known = {spec.json: spec for spec in specs if spec.json is not None}
|
|
518
|
+
given: dict[str, Any] = {}
|
|
519
|
+
for key, value in doc.items():
|
|
520
|
+
spec = known.get(key)
|
|
521
|
+
if spec is None:
|
|
522
|
+
raise FieldError(f"there is no {key!r} setting here")
|
|
523
|
+
if value is not None:
|
|
524
|
+
given[spec.name] = value
|
|
525
|
+
return values(specs, given)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def settable(specs: Specs) -> Iterable[FieldSpec]:
|
|
529
|
+
"""Every field a caller may write."""
|
|
530
|
+
return (spec for spec in specs if spec.writable)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
__all__ = [
|
|
534
|
+
"ENUM",
|
|
535
|
+
"FLAG",
|
|
536
|
+
"INTEGER",
|
|
537
|
+
"NUMBER",
|
|
538
|
+
"ON_OFF",
|
|
539
|
+
"ON_OFF_CODES",
|
|
540
|
+
"READ_ONLY",
|
|
541
|
+
"READ_WRITE",
|
|
542
|
+
"TEXT",
|
|
543
|
+
"FieldError",
|
|
544
|
+
"FieldSpec",
|
|
545
|
+
"add_arguments",
|
|
546
|
+
"amount",
|
|
547
|
+
"argument",
|
|
548
|
+
"bounds",
|
|
549
|
+
"by_flag",
|
|
550
|
+
"by_name",
|
|
551
|
+
"coerce",
|
|
552
|
+
"decode",
|
|
553
|
+
"display",
|
|
554
|
+
"document",
|
|
555
|
+
"from_document",
|
|
556
|
+
"from_namespace",
|
|
557
|
+
"harvest",
|
|
558
|
+
"help_text",
|
|
559
|
+
"option_tables",
|
|
560
|
+
"rows",
|
|
561
|
+
"settable",
|
|
562
|
+
"values",
|
|
563
|
+
"writes",
|
|
564
|
+
]
|
devicectl/meta.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Where a program's own URLs come from: the packaging metadata it ships with.
|
|
2
|
+
|
|
3
|
+
A page that links to the project's website, to the release notes beside its
|
|
4
|
+
version number and to the licence it is under needs three URLs. Written into
|
|
5
|
+
the JavaScript they are three string constants per program, repeated in the
|
|
6
|
+
README, in the server's start-up banner and in whatever else grows a link --
|
|
7
|
+
and every one of them is a rename away from pointing at nothing.
|
|
8
|
+
|
|
9
|
+
They are already written down once, in ``[project.urls]``:
|
|
10
|
+
|
|
11
|
+
.. code-block:: toml
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Homepage = "https://github.com/pbasista/jkctl"
|
|
15
|
+
Releases = "https://github.com/pbasista/jkctl/releases"
|
|
16
|
+
License = "https://github.com/pbasista/jkctl/blob/main/LICENSE"
|
|
17
|
+
|
|
18
|
+
which the build back end writes into the installed distribution as
|
|
19
|
+
``Project-URL`` headers. This reads them back. Labels are matched loosely --
|
|
20
|
+
case, spaces and punctuation are ignored -- because the ones PyPI recognises
|
|
21
|
+
are spelled a dozen ways ("Bug Tracker", "bug-tracker") and nothing enforces
|
|
22
|
+
any of them.
|
|
23
|
+
|
|
24
|
+
A checkout that was never installed has no metadata at all, and a lookup that
|
|
25
|
+
finds nothing returns nothing rather than raising: a missing link is a
|
|
26
|
+
wordmark that is not clickable, which is not a reason to refuse to serve the
|
|
27
|
+
page.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
from importlib.metadata import PackageNotFoundError, metadata
|
|
34
|
+
|
|
35
|
+
# Everything but letters and digits: "Bug Tracker", "bug-tracker" and
|
|
36
|
+
# "bugtracker" are one label.
|
|
37
|
+
_NOT_ALNUM = re.compile(r"[^a-z0-9]+")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _key(label: str) -> str:
|
|
41
|
+
"""Return the label a URL is filed under, ignoring case and punctuation."""
|
|
42
|
+
return _NOT_ALNUM.sub("", label.strip().lower())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def project_links(distribution: str) -> dict[str, str]:
|
|
46
|
+
"""Return ``[project.urls]`` for an installed distribution, by loose label.
|
|
47
|
+
|
|
48
|
+
``project_links("jkctl")["homepage"]`` is the ``Homepage`` line of its
|
|
49
|
+
``pyproject.toml``. An uninstalled or unknown distribution gives ``{}``.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
info = metadata(distribution)
|
|
53
|
+
except PackageNotFoundError:
|
|
54
|
+
return {}
|
|
55
|
+
links: dict[str, str] = {}
|
|
56
|
+
for entry in info.get_all("Project-URL") or ():
|
|
57
|
+
label, _, url = str(entry).partition(",")
|
|
58
|
+
url = url.strip()
|
|
59
|
+
if url:
|
|
60
|
+
links.setdefault(_key(label), url)
|
|
61
|
+
return links
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = ["project_links"]
|
devicectl/paths.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Where a program keeps its per-user files, chosen the same way everywhere.
|
|
2
|
+
|
|
3
|
+
A device tool has a configuration file, and often a cached token or a small
|
|
4
|
+
state file beside it. Where those live is not the program's own decision to
|
|
5
|
+
make twice: it is the platform's convention, and the two programs here had
|
|
6
|
+
each written the same resolution out under their own directory name -- close
|
|
7
|
+
enough to drift (one docstring named macOS, the other did not) and identical
|
|
8
|
+
enough to share.
|
|
9
|
+
|
|
10
|
+
The directory name is the one thing that is the program's: :func:`config_dir`
|
|
11
|
+
takes it and applies the convention.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def config_dir(name: str) -> Path:
|
|
22
|
+
"""Return the directory a program named ``name`` keeps its config in.
|
|
23
|
+
|
|
24
|
+
``XDG_CONFIG_HOME`` wins wherever it is set, so a dotfiles setup that
|
|
25
|
+
exports it keeps working. Otherwise Windows uses ``%APPDATA%``, where
|
|
26
|
+
Windows programs keep per-user settings; everywhere else uses
|
|
27
|
+
``~/.config``, which is both the XDG default and where command-line
|
|
28
|
+
tools put themselves on macOS.
|
|
29
|
+
"""
|
|
30
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
31
|
+
if xdg:
|
|
32
|
+
return Path(xdg).expanduser() / name
|
|
33
|
+
if sys.platform == "win32":
|
|
34
|
+
appdata = os.environ.get("APPDATA")
|
|
35
|
+
if appdata:
|
|
36
|
+
return Path(appdata) / name
|
|
37
|
+
return Path.home() / ".config" / name
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = ["config_dir"]
|