diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-14 22:20:15 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-14 22:20:15 +0200 |
| commit | 422647e7898b8df67cd11f8fb060aec23dec84aa (patch) | |
| tree | 7ecca0e8df1bc8a7d26cf4b471bd44906dd9cfd1 /packages/meshbay-cli/src/meshbay_cli/output.py | |
| parent | 28c88908b88c58cba4de08710f6bb268ede02d57 (diff) | |
| download | meshbay-422647e7898b8df67cd11f8fb060aec23dec84aa.tar.gz | |
feat(cli): meshbay-cli, a command-line client for members
Signs in to the hub, lists the account's groups and their folders, and
downloads and uploads files directly with each group's node, end-to-end
encrypted. With no argument it opens a two-pane interactive mode (Textual).
Session and device keys are kept encrypted under the system's key storage,
or a 0600 key file where there is none; node keys are pinned at first
contact; a new machine links as a device.
Packaged on its own on Linux (deb and rpm, on meshbay-common's venv, with a
man page generated from the parser) and frozen next to meshbay-node in the
full Windows installer, not the light one, with a self-test the build runs.
client/keys.py ports keyderive.js and is LGPL like it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/output.py')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/output.py | 140 |
1 files changed, 140 insertions, 0 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/output.py b/packages/meshbay-cli/src/meshbay_cli/output.py new file mode 100644 index 0000000..75a54ff --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/output.py @@ -0,0 +1,140 @@ +""" +Writing to the terminal. + +**Names are someone else's text.** A file or group name is chosen by whoever put +it in the group, and a raw ESC in it is an escape sequence the reader's +terminal executes; a bidirectional override makes it display as another name. +Every name printed goes through `safe()`. +""" + +import json +import shutil +import sys +import time +import unicodedata + +_BIDI = frozenset("") + + +def safe(text: str) -> str: + out = [] + for c in text: + if unicodedata.category(c) == "Cc" or c in _BIDI: + out.append(f"\\x{ord(c):02x}" if ord(c) < 0x100 else f"\\u{ord(c):04x}") + else: + out.append(c) + return "".join(out) + + +def human_size(n: int) -> str: + size = float(n) + for unit in ("B", "KB", "MB", "GB", "TB"): + if size < 1024 or unit == "TB": + return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}" + size /= 1024 + return f"{n} B" + + +def date(ts: int) -> str: + return time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) if ts else "" + + +def columns(names: list[str]) -> str: + """`ls`'s short form on a terminal: as many columns as fit, filled down.""" + if not names: + return "" + width = shutil.get_terminal_size((80, 24)).columns + col = max(len(n) for n in names) + 2 + ncols = max(1, width // col) + nrows = -(-len(names) // ncols) + lines = [] + for r in range(nrows): + row = [names[c * nrows + r] for c in range(ncols) if c * nrows + r < len(names)] + lines.append("".join(n.ljust(col) for n in row).rstrip()) + return "\n".join(lines) + + +def is_tty() -> bool: + return sys.stdout.isatty() + + +def err(message: str) -> None: + print(message, file=sys.stderr) + + +class Progress: + """ + One status line on stderr for a run of transfers, redrawn at most ten times + a second; JSON Lines with --json; nothing with -q or when stderr is not a + terminal. + """ + + def __init__(self, arrow: str, *, quiet: bool = False, as_json: bool = False): + self.arrow = arrow + self.live = sys.stderr.isatty() and not quiet and not as_json + self.as_json = as_json and not quiet + self.files_total = 0 + self.files_done = 0 + self.bytes_total = 0 + self.bytes_done = 0 + self.current = "" + self._start = time.monotonic() + self._drawn = 0.0 + + def plan(self, files: int, nbytes: int) -> None: + self.files_total, self.bytes_total = files, nbytes + + def event(self, **fields) -> None: + if self.as_json: + print(json.dumps(fields, ensure_ascii=False), file=sys.stderr, flush=True) + + def start(self, name: str) -> None: + self.current = name + self.event(event="start", name=name) + self._draw(force=True) + + def advance(self, nbytes: int) -> None: + self.bytes_done += nbytes + self._draw() + + def queued(self, name: str, ahead: int) -> None: + self.current = f"{name} (waiting for a free slot on the node, {ahead} ahead)" + self.event(event="queued", name=name, ahead=ahead) + self._draw(force=True) + + def finish(self, name: str, **fields) -> None: + self.files_done += 1 + self.event(event="done", name=name, **fields) + self._draw(force=True) + + def note(self, message: str) -> None: + """A line of its own, above the status line.""" + self.clear() + if not self.as_json: + print(message, file=sys.stderr) + self._draw(force=True) + + def _draw(self, force: bool = False) -> None: + if not self.live: + return + now = time.monotonic() + if not force and now - self._drawn < 0.1: + return + self._drawn = now + rate = self.bytes_done / max(now - self._start, 0.001) + line = (f"{self.arrow} {self.files_done}/{self.files_total} " + f"{human_size(self.bytes_done)} / {human_size(self.bytes_total)} " + f"{human_size(int(rate))}/s {self.current}") + width = shutil.get_terminal_size((80, 24)).columns + sys.stderr.write("\r\x1b[K" + line[:width - 1]) + sys.stderr.flush() + + def clear(self) -> None: + if self.live: + sys.stderr.write("\r\x1b[K") + sys.stderr.flush() + + +def sentence(text: str) -> str: + """The first letter up, the rest untouched: `str.capitalize` turns -R into -r.""" + return text[:1].upper() + text[1:] |