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/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Shared building blocks for single-device control programs.
|
|
2
|
+
|
|
3
|
+
Two programs -- one for a wallbox on the network, one for a battery
|
|
4
|
+
management system on a serial bus -- turned out to be the same program with
|
|
5
|
+
different protocols underneath: a subcommand table over argparse, a
|
|
6
|
+
serialising worker that owns the one connection, an event stream to a
|
|
7
|
+
build-free browser UI, and a progress protocol that lets one code path drive
|
|
8
|
+
both a terminal and that UI. Everything here is the part that was the same.
|
|
9
|
+
|
|
10
|
+
Nothing in this package knows what a device is. It has no required runtime
|
|
11
|
+
dependencies and imports nothing outside the standard library, so the
|
|
12
|
+
protocol layer -- ``httpx``, ``pyserial``, whatever the next one needs --
|
|
13
|
+
stays in the program that speaks it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The command-line half: the subcommand table, exit codes and terminal output."""
|
devicectl/cli/command.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""What one subcommand is: a function to run, and what it needs open first.
|
|
2
|
+
|
|
3
|
+
The alternative -- a chain of ``if args.command == ...`` -- hid three
|
|
4
|
+
different things in the same shape: which function runs, whether the link to
|
|
5
|
+
the device is needed at all, and whether it has to be ready to use. Two
|
|
6
|
+
commands in thirty need less than the rest, and in a chain that is a special
|
|
7
|
+
case buried at the top; here it is a field.
|
|
8
|
+
|
|
9
|
+
The web API's ``ROUTES`` table is the same idea for the same reason.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
from collections.abc import Mapping, Sequence
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import Any, Callable
|
|
19
|
+
|
|
20
|
+
# Every handler takes the same two arguments whether it wants them or not, so
|
|
21
|
+
# the table can stay one shape. What arrives first depends on the Need the
|
|
22
|
+
# command declares -- None, or whatever the program opened -- and each handler
|
|
23
|
+
# annotates the one it actually takes. No single signature describes all
|
|
24
|
+
# three, so this one leaves the first parameter open rather than pretending
|
|
25
|
+
# otherwise.
|
|
26
|
+
Handler = Callable[[Any, argparse.Namespace], int]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Need(Enum):
|
|
30
|
+
"""What has to be open before a command's handler runs.
|
|
31
|
+
|
|
32
|
+
Three levels, because every program here has found it needs exactly
|
|
33
|
+
three. What they *mean* is the program's own: for a charger the link is
|
|
34
|
+
a connection and ready is a logged-in session; for a serial bus the link
|
|
35
|
+
is the open port and ready is one addressed unit on it.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
NOTHING = "nothing"
|
|
39
|
+
"""No device at all: browsing for one, or starting the web server."""
|
|
40
|
+
|
|
41
|
+
LINK = "link"
|
|
42
|
+
"""The link is open, but nothing has been selected or authenticated on it."""
|
|
43
|
+
|
|
44
|
+
READY = "ready"
|
|
45
|
+
"""Open, and usable: logged in, or addressed. Almost everything."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Command:
|
|
50
|
+
"""One subcommand."""
|
|
51
|
+
|
|
52
|
+
run: Handler
|
|
53
|
+
needs: Need = Need.READY
|
|
54
|
+
per_action: Mapping[str, Need] = field(default_factory=dict)
|
|
55
|
+
"""Actions of this command that need less than the command itself."""
|
|
56
|
+
|
|
57
|
+
default_action: str = ""
|
|
58
|
+
"""What this command does when its ACTION is left out.
|
|
59
|
+
|
|
60
|
+
Declared here rather than in a table beside the parser, because a
|
|
61
|
+
separate table has to be kept in step with this one by hand and fails
|
|
62
|
+
silently when it is not: a default named for a command that no longer
|
|
63
|
+
exists never fires, and a command that grew actions never gets one.
|
|
64
|
+
|
|
65
|
+
Only an action that needs no further input and changes nothing belongs
|
|
66
|
+
here. A command whose every action writes -- setting a password, say --
|
|
67
|
+
deliberately has none, so typing the bare command is a usage error
|
|
68
|
+
rather than a surprise.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
fans_out: bool | Sequence[str] = False
|
|
72
|
+
"""Whether this command may be run over several devices at once.
|
|
73
|
+
|
|
74
|
+
``True`` for a command that only reads, a list of action names for one
|
|
75
|
+
where only some of its actions do (``fans_out=("show",)``), and the
|
|
76
|
+
default for everything else. Only reads fan out: "do this to every
|
|
77
|
+
device I own" is not a thing to type by accident, and each of them wants
|
|
78
|
+
its own diff and its own confirmation anyway.
|
|
79
|
+
|
|
80
|
+
A program whose devices are addressed one at a time reads this before it
|
|
81
|
+
opens anything; :mod:`devicectl.cli.fanout` is what it does afterwards.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def need(self, action: str | None) -> Need:
|
|
85
|
+
"""Return what this command needs open, given the ACTION it was called with."""
|
|
86
|
+
return self.per_action.get(action or "", self.needs)
|
|
87
|
+
|
|
88
|
+
def fans_out_for(self, action: str | None) -> bool:
|
|
89
|
+
"""Whether this command fans out, given the ACTION it was called with."""
|
|
90
|
+
if isinstance(self.fans_out, bool):
|
|
91
|
+
return self.fans_out
|
|
92
|
+
return (action or "") in self.fans_out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
__all__ = ["Command", "Handler", "Need"]
|
devicectl/cli/exits.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""The exit codes every program here shares.
|
|
2
|
+
|
|
3
|
+
Zero is success, 130 is the shell's convention for Ctrl-C, and 1 is
|
|
4
|
+
everything else -- nothing at that address, no such setting, a value the
|
|
5
|
+
device refused. There is deliberately no code per failure kind; the message
|
|
6
|
+
on stderr says which.
|
|
7
|
+
|
|
8
|
+
A firmware upgrade is the usual exception. It is the one command likely to
|
|
9
|
+
be run unattended across a fleet, where "this image is for another model"
|
|
10
|
+
and "it never came back" call for different reactions, so the two weights
|
|
11
|
+
that mean the same thing everywhere live here and a program adds whatever
|
|
12
|
+
further codes its own upgrade path can end in.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
EXIT_OK = 0
|
|
18
|
+
EXIT_ERROR = 1 # generic failure: nothing found, comms error, invalid input
|
|
19
|
+
EXIT_INTERRUPTED = 130 # Ctrl-C, by the usual shell convention
|
|
20
|
+
|
|
21
|
+
# --- firmware ------------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
EXIT_INCOMPATIBLE = 2 # the image is not for this device
|
|
24
|
+
EXIT_UPDATE_FAILED = 4 # sent, but the install reached no good state
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"EXIT_ERROR",
|
|
28
|
+
"EXIT_INCOMPATIBLE",
|
|
29
|
+
"EXIT_INTERRUPTED",
|
|
30
|
+
"EXIT_OK",
|
|
31
|
+
"EXIT_UPDATE_FAILED",
|
|
32
|
+
]
|
devicectl/cli/fanout.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Running one read over every device the command line named.
|
|
2
|
+
|
|
3
|
+
One device is the ordinary case and stays exactly as it was: no heading, no
|
|
4
|
+
wrapper, the same bytes on standard output. Several is the interesting one.
|
|
5
|
+
A handler prints its own output, which is right for one and wrong for four:
|
|
6
|
+
``--json`` over four devices should answer with one document keyed by
|
|
7
|
+
device, not with four documents concatenated into something no parser will
|
|
8
|
+
take. So the printing is diverted for the duration, and the handler never
|
|
9
|
+
learns that it fanned out.
|
|
10
|
+
|
|
11
|
+
Only reads fan out. A command that reconfigures or restarts a device keeps
|
|
12
|
+
its single target, because "do this to every one I own" is not a thing to
|
|
13
|
+
type by accident, and each of them wants its own diff and its own
|
|
14
|
+
confirmation anyway.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from typing import Any, Callable, TypeVar
|
|
21
|
+
|
|
22
|
+
from devicectl.cli.exits import EXIT_ERROR, EXIT_OK
|
|
23
|
+
from devicectl.cli.main import Translator
|
|
24
|
+
from devicectl.cli.output import collect_json, error, render_json, stop_collecting
|
|
25
|
+
from devicectl.errors import DeviceError
|
|
26
|
+
|
|
27
|
+
DeviceT = TypeVar("DeviceT")
|
|
28
|
+
|
|
29
|
+
# How wide the heading rule is drawn over a section.
|
|
30
|
+
HEADING_WIDTH = 56
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_range(text: str | int, limit: range, *, what: str = "id") -> list[int] | None:
|
|
34
|
+
"""Parse one address, a comma list, a range, or ``all``.
|
|
35
|
+
|
|
36
|
+
``None`` means "every address there is", which usually cannot be
|
|
37
|
+
resolved until something is open, so it is left for the caller to sweep
|
|
38
|
+
for. Order is kept and repeats are dropped, so ``2,1,2`` reads two
|
|
39
|
+
devices in the order asked for.
|
|
40
|
+
"""
|
|
41
|
+
if isinstance(text, int): # a caller that already had a number
|
|
42
|
+
text = str(text)
|
|
43
|
+
text = text.strip().lower()
|
|
44
|
+
if text in ("all", "*"):
|
|
45
|
+
return None
|
|
46
|
+
out: list[int] = []
|
|
47
|
+
for part in text.split(","):
|
|
48
|
+
part = part.strip()
|
|
49
|
+
if not part:
|
|
50
|
+
continue
|
|
51
|
+
if "-" in part[1:]: # a range, e.g. 1-4; a leading - is not one
|
|
52
|
+
low, _, high = part.partition("-")
|
|
53
|
+
out.extend(range(int(low), int(high) + 1))
|
|
54
|
+
else:
|
|
55
|
+
out.append(int(part))
|
|
56
|
+
if not out:
|
|
57
|
+
raise ValueError(f"cannot read --{what} {text!r}")
|
|
58
|
+
for value in out:
|
|
59
|
+
if value not in limit:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"--{what} {value} is outside {limit.start}..{limit.stop - 1}"
|
|
62
|
+
)
|
|
63
|
+
return list(dict.fromkeys(out))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def fan_out(
|
|
67
|
+
devices: Sequence[DeviceT],
|
|
68
|
+
one: Callable[[DeviceT], int],
|
|
69
|
+
*,
|
|
70
|
+
key: Callable[[DeviceT], str],
|
|
71
|
+
heading: Callable[[DeviceT], str],
|
|
72
|
+
as_json: bool = False,
|
|
73
|
+
translate: Translator | None = None,
|
|
74
|
+
) -> int:
|
|
75
|
+
"""Run ``one(device)`` over each device and combine the results.
|
|
76
|
+
|
|
77
|
+
With one device this is exactly what the handler did before. With
|
|
78
|
+
several, each section gets a heading, and ``--json`` comes back as one
|
|
79
|
+
object keyed by ``key`` rather than as several documents in a row.
|
|
80
|
+
|
|
81
|
+
The exit code is the first failure, so a bank with one silent device
|
|
82
|
+
says so, and the others are still read. ``translate`` says what a
|
|
83
|
+
program's own transport failures mean, exactly as it does for
|
|
84
|
+
:func:`devicectl.cli.main.run`: over several devices they have to be
|
|
85
|
+
caught here instead, or the first charger that does not answer would
|
|
86
|
+
take the other three with it.
|
|
87
|
+
"""
|
|
88
|
+
if not devices:
|
|
89
|
+
return EXIT_OK
|
|
90
|
+
if len(devices) == 1:
|
|
91
|
+
return one(devices[0])
|
|
92
|
+
combined: dict[str, Any] = {}
|
|
93
|
+
worst = EXIT_OK
|
|
94
|
+
for index, device in enumerate(devices):
|
|
95
|
+
if as_json:
|
|
96
|
+
collected = collect_json()
|
|
97
|
+
try:
|
|
98
|
+
code = _guarded(one, device, heading(device), translate)
|
|
99
|
+
finally:
|
|
100
|
+
stop_collecting()
|
|
101
|
+
combined[key(device)] = (
|
|
102
|
+
collected[0] if len(collected) == 1 else list(collected)
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
if index:
|
|
106
|
+
print()
|
|
107
|
+
title = heading(device)
|
|
108
|
+
print(f"=== {title} " + "=" * max(0, HEADING_WIDTH - len(title)))
|
|
109
|
+
print()
|
|
110
|
+
code = _guarded(one, device, title, translate)
|
|
111
|
+
worst = worst or code
|
|
112
|
+
if as_json:
|
|
113
|
+
render_json(combined)
|
|
114
|
+
return worst
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _guarded(
|
|
118
|
+
one: Callable[[Any], int],
|
|
119
|
+
device: Any,
|
|
120
|
+
title: str,
|
|
121
|
+
translate: Translator | None = None,
|
|
122
|
+
) -> int:
|
|
123
|
+
"""Run one device's read, turning a silent device into a message and a code.
|
|
124
|
+
|
|
125
|
+
Anything neither the shared error nor the program's ``translate``
|
|
126
|
+
recognises is left to propagate: a bug is a bug on four devices too, and
|
|
127
|
+
it deserves its traceback rather than a heading and a 1.
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
return one(device)
|
|
131
|
+
except DeviceError as exc:
|
|
132
|
+
error(f"{title}: {exc}")
|
|
133
|
+
return EXIT_ERROR
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
message = translate(exc) if translate is not None else None
|
|
136
|
+
if message is None:
|
|
137
|
+
raise
|
|
138
|
+
error(f"{title}: {message}")
|
|
139
|
+
return EXIT_ERROR
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
__all__ = ["fan_out", "parse_range"]
|
devicectl/cli/main.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Turning an expected failure into one line on stderr and an exit code.
|
|
2
|
+
|
|
3
|
+
Every module raises its own subclass of
|
|
4
|
+
:class:`~devicectl.errors.DeviceError`, and none of them needs a handler of
|
|
5
|
+
its own: they all end the same way. So a program's ``main`` is its dispatch
|
|
6
|
+
wrapped in :func:`run`, which is the only place a failure is printed.
|
|
7
|
+
|
|
8
|
+
A program that has a transport of its own -- HTTP, a serial port -- passes a
|
|
9
|
+
``translate`` to say what its exceptions mean, because "cannot reach the
|
|
10
|
+
charger: [Errno 111]" is a better line than the ``OSError`` underneath it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Callable
|
|
17
|
+
|
|
18
|
+
from devicectl.cli.exits import EXIT_ERROR, EXIT_INTERRUPTED
|
|
19
|
+
from devicectl.cli.output import error
|
|
20
|
+
from devicectl.errors import DeviceError
|
|
21
|
+
|
|
22
|
+
Translator = Callable[[BaseException], str | None]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run(
|
|
26
|
+
dispatch: Callable[[], int],
|
|
27
|
+
*,
|
|
28
|
+
translate: Translator | None = None,
|
|
29
|
+
interrupted: str = "Interrupted.",
|
|
30
|
+
) -> int:
|
|
31
|
+
"""Run ``dispatch``, reporting an expected failure rather than raising it.
|
|
32
|
+
|
|
33
|
+
``translate`` is asked first and returns the line to print, or ``None``
|
|
34
|
+
to let the shared handling decide. Anything neither recognises is left
|
|
35
|
+
to propagate: an unexpected exception is a bug, and a bug deserves its
|
|
36
|
+
traceback.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
return dispatch()
|
|
40
|
+
except KeyboardInterrupt:
|
|
41
|
+
# A newline first: Ctrl+C echoes at the cursor, wherever a progress
|
|
42
|
+
# line had left it.
|
|
43
|
+
print(f"\n{interrupted}", file=sys.stderr)
|
|
44
|
+
return EXIT_INTERRUPTED
|
|
45
|
+
except BaseException as exc:
|
|
46
|
+
message = translate(exc) if translate is not None else None
|
|
47
|
+
if message is None:
|
|
48
|
+
message = _message_for(exc)
|
|
49
|
+
if message is None:
|
|
50
|
+
raise
|
|
51
|
+
error(message)
|
|
52
|
+
return EXIT_ERROR
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _message_for(exc: BaseException) -> str | None:
|
|
56
|
+
"""Return the line for a failure any program of this kind can have."""
|
|
57
|
+
if isinstance(exc, DeviceError):
|
|
58
|
+
return str(exc)
|
|
59
|
+
if isinstance(exc, KeyError):
|
|
60
|
+
# Something named on the command line that does not exist. A
|
|
61
|
+
# KeyError's str() is the repr of its key, which reads as a quoted
|
|
62
|
+
# word rather than as a sentence, so unwrap it.
|
|
63
|
+
return str(exc.args[0]) if exc.args else str(exc)
|
|
64
|
+
if isinstance(exc, OSError):
|
|
65
|
+
return str(exc)
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
__all__ = ["Translator", "run"]
|
devicectl/cli/output.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Printing: tables, label/value blocks, the one timestamp format, the one prompt.
|
|
2
|
+
|
|
3
|
+
Every command that shows something goes through here, so column widths, JSON
|
|
4
|
+
shape and clock formatting stay the same across dozens of commands without
|
|
5
|
+
each one deciding for itself. :func:`confirm` is here for the same reason:
|
|
6
|
+
the commands that can wreck something should all ask about it the same way.
|
|
7
|
+
|
|
8
|
+
Messages on stderr open with ``error:`` when the command failed, ``warning:``
|
|
9
|
+
when it carried on regardless, and ``note:`` for an advisory -- so a script
|
|
10
|
+
reading stderr can tell the three apart without parsing prose.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
import unicodedata
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Callable
|
|
20
|
+
|
|
21
|
+
from devicectl.cli.exits import EXIT_ERROR, EXIT_OK
|
|
22
|
+
|
|
23
|
+
# How clock readings are printed: seconds matter, sub-seconds do not.
|
|
24
|
+
CLOCK_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
25
|
+
|
|
26
|
+
# Longest a value may be before the table view abbreviates it.
|
|
27
|
+
TABLE_VALUE_MAX = 40
|
|
28
|
+
|
|
29
|
+
# The answers that mean yes. Anything else, including silence, is no.
|
|
30
|
+
YES = ("y", "yes")
|
|
31
|
+
|
|
32
|
+
# The character widths `unicodedata` calls double.
|
|
33
|
+
WIDE = ("W", "F")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def confirm(question: str) -> bool:
|
|
37
|
+
"""Ask a yes/no question; anything but an explicit yes is a no.
|
|
38
|
+
|
|
39
|
+
End-of-file counts as a no, so a command left to run unattended stops
|
|
40
|
+
rather than raising at the prompt. Every command that asks also takes
|
|
41
|
+
``-y`` to skip the question; declining exits non-zero, because nothing
|
|
42
|
+
that was asked for was done.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
return input(f"{question} [y/N] ").strip().lower() in YES
|
|
46
|
+
except EOFError:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def display_width(text: str) -> int:
|
|
51
|
+
"""Return how many terminal columns ``text`` occupies.
|
|
52
|
+
|
|
53
|
+
A CJK character takes two columns while ``len`` counts it as one, so a
|
|
54
|
+
table padded by ``len`` comes out visibly ragged wherever one appears --
|
|
55
|
+
and a recovered protocol table is exactly where one appears.
|
|
56
|
+
"""
|
|
57
|
+
return sum(2 if unicodedata.east_asian_width(c) in WIDE else 1 for c in text)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _pad(text: str, width: int) -> str:
|
|
61
|
+
"""Left-align ``text`` in ``width`` terminal columns."""
|
|
62
|
+
return text + " " * max(0, width - display_width(text))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def print_table(headers: list[str], rows: list[list[str]]) -> None:
|
|
66
|
+
"""Print a left-aligned table with columns sized to their widest cell."""
|
|
67
|
+
if not rows:
|
|
68
|
+
return
|
|
69
|
+
widths = [display_width(h) for h in headers]
|
|
70
|
+
for row in rows:
|
|
71
|
+
for i, cell in enumerate(row):
|
|
72
|
+
widths[i] = max(widths[i], display_width(cell))
|
|
73
|
+
print(" ".join(_pad(h, widths[i]) for i, h in enumerate(headers)).rstrip())
|
|
74
|
+
for row in rows:
|
|
75
|
+
print(" ".join(_pad(c, widths[i]) for i, c in enumerate(row)).rstrip())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def print_rows(title: str, rows: list[tuple[str, str]]) -> None:
|
|
79
|
+
"""Print a titled block of aligned label/value lines."""
|
|
80
|
+
if not rows:
|
|
81
|
+
return
|
|
82
|
+
if title:
|
|
83
|
+
print(f"{title}:\n")
|
|
84
|
+
width = max(display_width(label) for label, _ in rows)
|
|
85
|
+
for label, value in rows:
|
|
86
|
+
print(f" {_pad(label, width)} {value}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def print_sections(
|
|
90
|
+
title: str, sections: list[tuple[str, list[tuple[str, str]]]]
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Print a titled block of label/value lines under headings.
|
|
93
|
+
|
|
94
|
+
For a reading a web page splits into cards: the headings are the cards'
|
|
95
|
+
titles, so a row can carry the card's short name for it ("Average" under
|
|
96
|
+
*Cells*) and still say what it is. The values line up across sections,
|
|
97
|
+
so the block reads as one list. An empty section is left out.
|
|
98
|
+
"""
|
|
99
|
+
sections = [(heading, rows) for heading, rows in sections if rows]
|
|
100
|
+
if not sections:
|
|
101
|
+
return
|
|
102
|
+
if title:
|
|
103
|
+
print(f"{title}:")
|
|
104
|
+
width = max(display_width(label) for _, rows in sections for label, _ in rows)
|
|
105
|
+
for heading, rows in sections:
|
|
106
|
+
print(f"\n {heading}")
|
|
107
|
+
for label, value in rows:
|
|
108
|
+
print(f" {_pad(label, width)} {value}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Where a --json document goes while a command is running over several
|
|
112
|
+
# devices.
|
|
113
|
+
#
|
|
114
|
+
# A handler prints its own JSON, which is right for one device and wrong for
|
|
115
|
+
# four: a fan-out should answer with one document keyed by device, not with
|
|
116
|
+
# four documents concatenated into something no parser will take.
|
|
117
|
+
# :mod:`devicectl.cli.fanout` redirects the printing here for the duration,
|
|
118
|
+
# so every --json command fans out without each of them learning how.
|
|
119
|
+
_collector: list[Any] | None = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def collect_json() -> list[Any]:
|
|
123
|
+
"""Divert :func:`print_json` into a list until :func:`stop_collecting`."""
|
|
124
|
+
global _collector
|
|
125
|
+
_collector = []
|
|
126
|
+
return _collector
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def stop_collecting() -> None:
|
|
130
|
+
"""Send :func:`print_json` back to standard output."""
|
|
131
|
+
global _collector
|
|
132
|
+
_collector = None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def print_json(doc: Any) -> None:
|
|
136
|
+
"""Print a document as the one JSON shape every command emits."""
|
|
137
|
+
if _collector is not None:
|
|
138
|
+
_collector.append(doc)
|
|
139
|
+
return
|
|
140
|
+
render_json(doc)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def render_json(doc: Any) -> None:
|
|
144
|
+
"""Print a document, whatever the collector is doing. For fanout's own use."""
|
|
145
|
+
print(json.dumps(doc, indent=2, default=_jsonable))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def shorten(value: Any, limit: int = TABLE_VALUE_MAX) -> str:
|
|
149
|
+
"""Return a short one-line rendering of a value, for tables and diffs."""
|
|
150
|
+
text = str(value)
|
|
151
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def note(message: str) -> None:
|
|
155
|
+
"""Print an advisory to stderr, so a script's own output is undisturbed."""
|
|
156
|
+
print(f"note: {message}", file=sys.stderr)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def warn(message: str) -> None:
|
|
160
|
+
"""Print a warning to stderr: the command carried on regardless."""
|
|
161
|
+
print(f"warning: {message}", file=sys.stderr)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def error(message: str) -> None:
|
|
165
|
+
"""Print a failure to stderr, with the prefix argparse itself uses."""
|
|
166
|
+
print(f"error: {message}", file=sys.stderr)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def aborted() -> None:
|
|
170
|
+
"""Say that a confirmation was declined. Not a malfunction."""
|
|
171
|
+
print("Aborted.", file=sys.stderr)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# --- the two ends of a file argument --------------------------------------------------------
|
|
175
|
+
#
|
|
176
|
+
# Two rules, and neither of them is ours: they are what a person who has used
|
|
177
|
+
# a Unix command line before already expects.
|
|
178
|
+
#
|
|
179
|
+
# A file argument of ``-`` is the standard stream -- output where a file
|
|
180
|
+
# would be written, input where one would be read. It is obeyed wherever it
|
|
181
|
+
# is typed, terminal or not: somebody who types it means it, and an explicit
|
|
182
|
+
# argument that is quietly ignored is the kind of surprise that makes a
|
|
183
|
+
# program feel untrustworthy on the day it is met.
|
|
184
|
+
#
|
|
185
|
+
# Nothing a person did not ask for is overwritten. Every path that would
|
|
186
|
+
# land on an existing file goes through :func:`may_overwrite`, which asks;
|
|
187
|
+
# ``-y``/``--yes`` is how a script says yes in advance.
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# What a file argument spells when it means the standard stream.
|
|
191
|
+
STDIO = "-"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def is_stdio(file: str | Path | None) -> bool:
|
|
195
|
+
"""Whether a file argument names the standard stream rather than a file."""
|
|
196
|
+
return file is not None and str(file) == STDIO
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def may_overwrite(path: Path, *, yes: bool = False) -> bool:
|
|
200
|
+
"""Whether ``path`` may be written, asking first if something is there.
|
|
201
|
+
|
|
202
|
+
Says "Aborted." itself on a no, because every caller said exactly that.
|
|
203
|
+
"""
|
|
204
|
+
if not path.exists() or yes:
|
|
205
|
+
return True
|
|
206
|
+
if confirm(f"'{path}' already exists. Overwrite?"):
|
|
207
|
+
return True
|
|
208
|
+
aborted()
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def read_in(file: str | Path | None) -> str:
|
|
213
|
+
"""Return the text of ``file``, or of standard input when it is ``-``.
|
|
214
|
+
|
|
215
|
+
The reading half of :func:`write_out`, and the reason every command that
|
|
216
|
+
takes a file takes a pipe as well: ``curl ... | alfenctl import -``. A
|
|
217
|
+
missing file raises :class:`OSError`, which callers with something to add
|
|
218
|
+
("cannot read the tag list: ...") catch, and the rest let the error
|
|
219
|
+
funnel print.
|
|
220
|
+
|
|
221
|
+
Binary inputs keep their own path: a firmware package is identified partly
|
|
222
|
+
by its filename, and a stream has none.
|
|
223
|
+
"""
|
|
224
|
+
if file is None or is_stdio(file):
|
|
225
|
+
return sys.stdin.read()
|
|
226
|
+
return Path(file).read_text(encoding="utf-8")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def write_out(
|
|
230
|
+
payload: str | bytes,
|
|
231
|
+
file: str | Path | None,
|
|
232
|
+
*,
|
|
233
|
+
default_name: str | Callable[[], str],
|
|
234
|
+
yes: bool = False,
|
|
235
|
+
summary: str = "",
|
|
236
|
+
) -> int:
|
|
237
|
+
"""Write ``payload`` where the caller asked, and return an exit code.
|
|
238
|
+
|
|
239
|
+
``-`` is standard output, on a terminal as much as through a pipe. With
|
|
240
|
+
no file named at all there is nothing to obey, so the two differ: a pipe
|
|
241
|
+
gets the bytes, and a terminal gets ``default_name`` in the working
|
|
242
|
+
directory, because a dump nobody redirected would only scroll past.
|
|
243
|
+
|
|
244
|
+
``default_name`` may be a callable, for the callers that have to ask the
|
|
245
|
+
device its name to build one: a piped export then never asks.
|
|
246
|
+
|
|
247
|
+
``summary`` says what was written ("412 log lines"), for the callers that
|
|
248
|
+
would rather count than just name the path.
|
|
249
|
+
"""
|
|
250
|
+
target: str | Path | None = file
|
|
251
|
+
if target is None and sys.stdout.isatty():
|
|
252
|
+
target = default_name if isinstance(default_name, str) else default_name()
|
|
253
|
+
if target is None or is_stdio(target):
|
|
254
|
+
if isinstance(payload, str):
|
|
255
|
+
sys.stdout.write(payload)
|
|
256
|
+
else:
|
|
257
|
+
sys.stdout.buffer.write(payload)
|
|
258
|
+
return EXIT_OK
|
|
259
|
+
path = Path(str(target))
|
|
260
|
+
if not may_overwrite(path, yes=yes):
|
|
261
|
+
return EXIT_ERROR
|
|
262
|
+
if isinstance(payload, str):
|
|
263
|
+
path.write_text(payload, encoding="utf-8")
|
|
264
|
+
else:
|
|
265
|
+
path.write_bytes(payload)
|
|
266
|
+
print(f"Wrote {summary} to {path}" if summary else f"Wrote {path}", file=sys.stderr)
|
|
267
|
+
return EXIT_OK
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _jsonable(value: Any) -> Any:
|
|
271
|
+
"""Render the few non-JSON types a decoded field can hold."""
|
|
272
|
+
if isinstance(value, (bytes, bytearray)):
|
|
273
|
+
return value.hex()
|
|
274
|
+
return str(value)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
__all__ = [
|
|
278
|
+
"CLOCK_FORMAT",
|
|
279
|
+
"STDIO",
|
|
280
|
+
"TABLE_VALUE_MAX",
|
|
281
|
+
"aborted",
|
|
282
|
+
"collect_json",
|
|
283
|
+
"confirm",
|
|
284
|
+
"display_width",
|
|
285
|
+
"error",
|
|
286
|
+
"is_stdio",
|
|
287
|
+
"may_overwrite",
|
|
288
|
+
"note",
|
|
289
|
+
"print_json",
|
|
290
|
+
"print_rows",
|
|
291
|
+
"print_sections",
|
|
292
|
+
"print_table",
|
|
293
|
+
"read_in",
|
|
294
|
+
"render_json",
|
|
295
|
+
"shorten",
|
|
296
|
+
"stop_collecting",
|
|
297
|
+
"warn",
|
|
298
|
+
"write_out",
|
|
299
|
+
]
|