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.
Files changed (47) hide show
  1. devicectl/__init__.py +18 -0
  2. devicectl/cli/__init__.py +1 -0
  3. devicectl/cli/command.py +95 -0
  4. devicectl/cli/exits.py +32 -0
  5. devicectl/cli/fanout.py +142 -0
  6. devicectl/cli/main.py +69 -0
  7. devicectl/cli/output.py +299 -0
  8. devicectl/cli/parser.py +80 -0
  9. devicectl/cli/report.py +86 -0
  10. devicectl/cli/target.py +26 -0
  11. devicectl/clock.py +57 -0
  12. devicectl/devtools/__init__.py +6 -0
  13. devicectl/devtools/frontlint.py +935 -0
  14. devicectl/devtools/htmcheck.py +396 -0
  15. devicectl/devtools/rendercheck.py +384 -0
  16. devicectl/doctor.py +112 -0
  17. devicectl/errors.py +68 -0
  18. devicectl/fields.py +564 -0
  19. devicectl/meta.py +64 -0
  20. devicectl/paths.py +40 -0
  21. devicectl/progress.py +77 -0
  22. devicectl/report.py +67 -0
  23. devicectl/testing.py +199 -0
  24. devicectl/trace.py +333 -0
  25. devicectl/web/__init__.py +1 -0
  26. devicectl/web/agents.py +94 -0
  27. devicectl/web/events.py +171 -0
  28. devicectl/web/http.py +243 -0
  29. devicectl/web/progress.py +101 -0
  30. devicectl/web/server.py +1013 -0
  31. devicectl/web/static/core.css +3034 -0
  32. devicectl/web/static/js/api.js +198 -0
  33. devicectl/web/static/js/band.js +640 -0
  34. devicectl/web/static/js/chart.js +400 -0
  35. devicectl/web/static/js/drafts.js +312 -0
  36. devicectl/web/static/js/notify.js +272 -0
  37. devicectl/web/static/js/panels.js +432 -0
  38. devicectl/web/static/js/shell.js +672 -0
  39. devicectl/web/static/js/trace.js +133 -0
  40. devicectl/web/static/js/ui.js +1139 -0
  41. devicectl/web/static/vendor/preact-htm.module.js +27 -0
  42. devicectl/web/worker.py +697 -0
  43. devicectl_core-0.1.0.dist-info/METADATA +131 -0
  44. devicectl_core-0.1.0.dist-info/RECORD +47 -0
  45. devicectl_core-0.1.0.dist-info/WHEEL +4 -0
  46. devicectl_core-0.1.0.dist-info/licenses/LICENSE +287 -0
  47. 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."""
@@ -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
+ ]
@@ -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"]
@@ -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
+ ]