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/cli/parser.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Filling in what the command line left out.
|
|
2
|
+
|
|
3
|
+
Two small rewrites of ``argv`` before argparse ever sees it. Both exist for
|
|
4
|
+
the same reason: the options a command needs live on its *action* parsers,
|
|
5
|
+
so a word left out is not merely a missing word -- it takes the options with
|
|
6
|
+
it, and argparse's complaint names the wrong thing.
|
|
7
|
+
|
|
8
|
+
Both leave a leading word alone, so a mistyped command or action still gets
|
|
9
|
+
argparse's "invalid choice" rather than being quietly handed somewhere else,
|
|
10
|
+
and both leave ``-h`` alone, because it has to reach the parser that lists
|
|
11
|
+
the choices.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Mapping, Sequence
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
# Options that must reach the root parser rather than a subcommand.
|
|
20
|
+
ROOT_OPTIONS = ("-h", "--help", "--version")
|
|
21
|
+
|
|
22
|
+
HELP_OPTIONS = ("-h", "--help")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def insert_default_action(
|
|
26
|
+
argv: Sequence[str], defaults: Mapping[str, str]
|
|
27
|
+
) -> list[str]:
|
|
28
|
+
"""Return ``argv`` with a command's default ACTION filled in, if missing.
|
|
29
|
+
|
|
30
|
+
``<prog> tags`` becomes ``<prog> tags list``, and options meant for that
|
|
31
|
+
default action are handed through to it (``<prog> scn --peers`` becomes
|
|
32
|
+
``<prog> scn status --peers``).
|
|
33
|
+
"""
|
|
34
|
+
argv = list(argv)
|
|
35
|
+
if not argv or argv[0] not in defaults:
|
|
36
|
+
return argv
|
|
37
|
+
rest = argv[1:]
|
|
38
|
+
if rest and (not rest[0].startswith("-") or rest[0] in HELP_OPTIONS):
|
|
39
|
+
return argv
|
|
40
|
+
return [argv[0], defaults[argv[0]], *rest]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def insert_default_command(argv: Sequence[str], command: str) -> list[str]:
|
|
44
|
+
"""Return ``argv`` with ``command`` filled in when no command was typed.
|
|
45
|
+
|
|
46
|
+
For a program with an obvious thing to do when run bare -- serving its
|
|
47
|
+
web interface, usually -- because a page of usage text is not what
|
|
48
|
+
somebody who typed the bare name came for. Options meant for it are
|
|
49
|
+
handed through, so ``<prog> --port 8080`` reaches that command rather
|
|
50
|
+
than being told ``--port`` belongs to a subcommand.
|
|
51
|
+
"""
|
|
52
|
+
argv = list(argv)
|
|
53
|
+
if not argv:
|
|
54
|
+
return [command]
|
|
55
|
+
first = argv[0]
|
|
56
|
+
if first in ROOT_OPTIONS or not first.startswith("-"):
|
|
57
|
+
return argv
|
|
58
|
+
return [command, *argv]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def default_actions(commands: Mapping[str, Any]) -> dict[str, str]:
|
|
62
|
+
"""Read every command's default ACTION out of the command table.
|
|
63
|
+
|
|
64
|
+
The alternative is a second table listing them, which has to be kept in
|
|
65
|
+
step with the first by hand and silently does nothing when it is not --
|
|
66
|
+
a default named for a command that no longer exists never fires, and a
|
|
67
|
+
command that grew actions never gets one.
|
|
68
|
+
"""
|
|
69
|
+
return {
|
|
70
|
+
name: command.default_action
|
|
71
|
+
for name, command in commands.items()
|
|
72
|
+
if getattr(command, "default_action", "")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"default_actions",
|
|
78
|
+
"insert_default_action",
|
|
79
|
+
"insert_default_command",
|
|
80
|
+
]
|
devicectl/cli/report.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Drawing a slow operation's progress on a terminal.
|
|
2
|
+
|
|
3
|
+
The one live stderr line, redrawn as things move and ended with a newline
|
|
4
|
+
when the phase is over. Two things turn it off: a pipe, where nothing would
|
|
5
|
+
ever erase the escape codes, and a debug or trace mode, whose log lines
|
|
6
|
+
would shred it. Both fall back to plain lines, which is also what a log
|
|
7
|
+
file wants.
|
|
8
|
+
|
|
9
|
+
What a transfer *counts* is the program's own -- bytes for one, 128-byte
|
|
10
|
+
blocks for another -- so :meth:`Reporter.sending` is left to the subclass.
|
|
11
|
+
Everything around it, including closing off a half-drawn line when a failure
|
|
12
|
+
lands on top of it, is here.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import sys
|
|
18
|
+
from types import TracebackType
|
|
19
|
+
|
|
20
|
+
from devicectl.progress import end_live, write_live
|
|
21
|
+
from devicectl.report import Reporter
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TerminalReporter(Reporter):
|
|
25
|
+
"""Report a slow operation's progress to the terminal.
|
|
26
|
+
|
|
27
|
+
Use it as a context manager: leaving the block closes off a live line
|
|
28
|
+
that a failure would otherwise have left half-drawn, with the error
|
|
29
|
+
message landing on top of it.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, *, quiet: bool = False) -> None:
|
|
33
|
+
"""Report to stderr, drawing a live line unless ``quiet`` or a pipe.
|
|
34
|
+
|
|
35
|
+
``quiet`` is what a program's ``--debug`` or ``--trace`` passes: its
|
|
36
|
+
own logging is going to the same stream, and the two cannot share a
|
|
37
|
+
line that is redrawn in place.
|
|
38
|
+
"""
|
|
39
|
+
self.quiet = quiet
|
|
40
|
+
self.live = not quiet and sys.stderr.isatty()
|
|
41
|
+
self._drawn = False # a live line is on screen, awaiting its newline
|
|
42
|
+
self._last_draw = 0.0
|
|
43
|
+
|
|
44
|
+
def __enter__(self) -> "TerminalReporter":
|
|
45
|
+
"""Return the reporter; nothing is drawn until something happens."""
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def __exit__(
|
|
49
|
+
self,
|
|
50
|
+
exc_type: type[BaseException] | None,
|
|
51
|
+
exc: BaseException | None,
|
|
52
|
+
tb: TracebackType | None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""End any live line, so the next thing printed starts on its own."""
|
|
55
|
+
self._close()
|
|
56
|
+
|
|
57
|
+
def _close(self) -> None:
|
|
58
|
+
"""Finish the live line, if one is on screen."""
|
|
59
|
+
if self._drawn:
|
|
60
|
+
end_live()
|
|
61
|
+
self._drawn = False
|
|
62
|
+
|
|
63
|
+
def _draw(self, text: str) -> None:
|
|
64
|
+
"""Put ``text`` on the live line (no-op when there is no live line)."""
|
|
65
|
+
if not self.live:
|
|
66
|
+
return
|
|
67
|
+
write_live(text)
|
|
68
|
+
self._drawn = True
|
|
69
|
+
|
|
70
|
+
def step(self, message: str) -> None:
|
|
71
|
+
"""Print the new phase on a line of its own."""
|
|
72
|
+
self._close()
|
|
73
|
+
print(f"{message}...")
|
|
74
|
+
|
|
75
|
+
def detail(self, message: str) -> None:
|
|
76
|
+
"""Print a detail, indented under the phase it belongs to."""
|
|
77
|
+
self._close()
|
|
78
|
+
print(f" {message}")
|
|
79
|
+
|
|
80
|
+
def warn(self, message: str) -> None:
|
|
81
|
+
"""Print a warning to stderr."""
|
|
82
|
+
self._close()
|
|
83
|
+
print(f"warning: {message}", file=sys.stderr)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
__all__ = ["TerminalReporter"]
|
devicectl/cli/target.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Choosing between sources of the same setting.
|
|
2
|
+
|
|
3
|
+
Which device a command talks to can be answered by the command line, by a
|
|
4
|
+
named table in the config file, by that file's own defaults, or by whatever
|
|
5
|
+
the program can discover for itself. Each program resolves that its own way,
|
|
6
|
+
but they all resolve it by asking the same question of one value after
|
|
7
|
+
another, and this is that question.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TypeVar
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def first_set(*values: T | None, default: T) -> T:
|
|
18
|
+
"""Return the first value that was actually given, else ``default``.
|
|
19
|
+
|
|
20
|
+
Sources are passed most specific first, so a precedence rule reads as one
|
|
21
|
+
line instead of a stack of conditionals.
|
|
22
|
+
"""
|
|
23
|
+
return next((value for value in values if value is not None), default)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = ["first_set"]
|
devicectl/clock.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""How far a device's clock is from this computer's, in words.
|
|
2
|
+
|
|
3
|
+
Both programs show it, on the same card of the same dashboard, and each had
|
|
4
|
+
its own sentence for it: one said "3.5 hours ahead of this computer", the
|
|
5
|
+
other printed a bare "03h 30m" beside a label and left the direction out
|
|
6
|
+
altogether. One phrase now, with the direction in it and the computer left
|
|
7
|
+
out of it -- the page is often read on a different machine from the one the
|
|
8
|
+
program runs on, and "this computer" is then the wrong one.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from devicectl.progress import SECONDS_PER_DAY, SECONDS_PER_HOUR, SECONDS_PER_MINUTE
|
|
14
|
+
|
|
15
|
+
# Closer than this and the two clocks are the same clock. A reading takes a
|
|
16
|
+
# round trip, and a device that answers in a second and a half is not a
|
|
17
|
+
# device a second and a half out.
|
|
18
|
+
DRIFT_NOISE_S = 2.0
|
|
19
|
+
|
|
20
|
+
SECONDS_PER_YEAR = 365 * SECONDS_PER_DAY
|
|
21
|
+
|
|
22
|
+
IN_SYNC = "in sync"
|
|
23
|
+
|
|
24
|
+
# The scale matters: a device that has never been set is not seconds out but
|
|
25
|
+
# years, since it boots with its clock at whatever its firmware was built on.
|
|
26
|
+
_UNITS = (
|
|
27
|
+
(SECONDS_PER_MINUTE, 1, "second", 0),
|
|
28
|
+
(SECONDS_PER_HOUR, SECONDS_PER_MINUTE, "minute", 0),
|
|
29
|
+
(SECONDS_PER_DAY, SECONDS_PER_HOUR, "hour", 1),
|
|
30
|
+
(SECONDS_PER_YEAR, SECONDS_PER_DAY, "day", 1),
|
|
31
|
+
(None, SECONDS_PER_YEAR, "year", 1),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def format_drift(seconds: float | None) -> str | None:
|
|
36
|
+
"""Say ``12 seconds ahead``, ``2.5 years behind`` or ``in sync``.
|
|
37
|
+
|
|
38
|
+
Positive is a device clock ahead of this computer's. None -- a device
|
|
39
|
+
that reports no time -- is handed back as None, because what to say
|
|
40
|
+
instead is the caller's: a charger and a battery do not fail to have a
|
|
41
|
+
clock for the same reason.
|
|
42
|
+
"""
|
|
43
|
+
if seconds is None:
|
|
44
|
+
return None
|
|
45
|
+
size = abs(seconds)
|
|
46
|
+
if size < DRIFT_NOISE_S:
|
|
47
|
+
return IN_SYNC
|
|
48
|
+
for below, per, word, digits in _UNITS:
|
|
49
|
+
if below is None or size < below:
|
|
50
|
+
amount = round(size / per, digits)
|
|
51
|
+
said = f"{amount:.{digits}f}"
|
|
52
|
+
plural = "" if amount == 1 else "s"
|
|
53
|
+
return f"{said} {word}{plural} {'ahead' if seconds > 0 else 'behind'}"
|
|
54
|
+
raise AssertionError("unreachable: the last unit has no upper bound")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = ["DRIFT_NOISE_S", "IN_SYNC", "format_drift"]
|