backpack-backbone 0.2.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.
backbone/output.py ADDED
@@ -0,0 +1,216 @@
1
+ """The one place the CLI decides what its output looks like.
2
+
3
+ Two modes, one set of calls. A command says *what* it produced (a table, a
4
+ record, an event, a failure) and never how to render it, so adding `--json`
5
+ needed no second code path and a new command gets both modes for free.
6
+
7
+ Human tables are laid out by `prompt.core._table_widths` / `_render_table_row`,
8
+ the same engine every list in the app uses, so a CLI table and a browse list
9
+ agree about widths, truncation and which column drops first on a narrow
10
+ terminal. Colour comes from `ui.Colors`, switched off process-wide by
11
+ `ui.set_colour` rather than checked here.
12
+
13
+ **Piping.** A list command prints its table when stdout is a terminal and one
14
+ path per line when it is not, which is what `ls` does and what makes
15
+ `tool list | tool read` work without a flag. `--json`
16
+ overrides both.
17
+
18
+ Exit codes are the module's other half: a command returns one, `cli.main`
19
+ passes it to the shell.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import sys
25
+
26
+ from backbone.prompt import core as pc
27
+ from backbone import ui
28
+
29
+ # Exit codes. 0/1 are the shell's own conventions; the rest name the failures a
30
+ # script would want to branch on without parsing a message.
31
+ OK = 0 # it worked
32
+ FAIL = 1 # it didn't, for a reason with no more specific code
33
+ USAGE = 2 # the arguments were wrong
34
+ NOT_FOUND = 3 # the thing asked for isn't there
35
+ EXISTS = 4 # the thing asked for is there already
36
+ NO_TOOL = 5 # a required external tool (ffmpeg, VLC) is missing
37
+
38
+ # Bumped when a field changes meaning or goes away, never for an addition, so a
39
+ # consumer can add fields without a version bump breaking it.
40
+ SCHEMA_VERSION = 1
41
+
42
+ _json = False
43
+ _quiet = False
44
+
45
+
46
+ def configure(*, json_mode: bool = False, quiet: bool = False,
47
+ colour: bool | None = None) -> None:
48
+ """Fix the output mode for the run. Called once, by `cli.main`.
49
+
50
+ `colour` defaults to `ui.colour_enabled()` (a terminal with NO_COLOR
51
+ unset), and JSON never carries colour whatever the terminal is.
52
+ """
53
+ global _json, _quiet
54
+ _json, _quiet = json_mode, quiet
55
+ if colour is None:
56
+ colour = ui.colour_enabled() and not json_mode
57
+ ui.set_colour(bool(colour))
58
+
59
+
60
+ def json_mode() -> bool:
61
+ """Whether this run is emitting JSON."""
62
+ return _json
63
+
64
+
65
+ def is_tty() -> bool:
66
+ """Whether stdout is a terminal: the switch between a table and bare paths."""
67
+ try:
68
+ return bool(sys.stdout.isatty())
69
+ except (AttributeError, ValueError):
70
+ return False
71
+
72
+
73
+ def _envelope(kind: str, body: dict) -> dict:
74
+ """Wrap a payload with the fields every JSON object carries."""
75
+ return {'schema': SCHEMA_VERSION, 'kind': kind, **body}
76
+
77
+
78
+ def _write(text: str = "") -> None:
79
+ """One line to stdout, flushed so a long run streams rather than buffers."""
80
+ sys.stdout.write(text + "\n")
81
+ sys.stdout.flush()
82
+
83
+
84
+ def record(kind: str, body: dict, *, human: str | None = None) -> None:
85
+ """One object: a JSON line, or `human` (falling back to aligned key/value).
86
+
87
+ Used for the single-subject commands: `library stat`, one track's tags.
88
+ """
89
+ if _json:
90
+ _write(json.dumps(_envelope(kind, body)))
91
+ return
92
+ if _quiet:
93
+ return
94
+ if human is not None:
95
+ _write(human)
96
+ return
97
+ width = max((len(str(k)) for k in body), default=0)
98
+ dim, reset = ui.Colors.DIM, ui.Colors.RESET
99
+ for key, value in body.items():
100
+ _write(f" {dim}{str(key).ljust(width)}{reset} {human_value(value)}")
101
+
102
+
103
+ def human_value(value) -> str:
104
+ """One config or record value on one line, for a person rather than a parser."""
105
+ if isinstance(value, bool):
106
+ return 'true' if value else 'false'
107
+ if isinstance(value, list):
108
+ return ', '.join(str(v) for v in value) or '(none)'
109
+ if isinstance(value, dict):
110
+ return f"({len(value)} entries)"
111
+ return str(value)
112
+
113
+
114
+ def table(kind: str, rows: list, columns: list, *,
115
+ cells=None, pipe_key: str = 'path') -> None:
116
+ """A list: JSON array, an aligned table on a terminal, or bare `pipe_key`s.
117
+
118
+ `rows` are dicts. `columns` are `prompt.core.Column` specs, and `cells` maps
119
+ one row to the list of strings those columns render, keeping the JSON
120
+ (whole objects) and the table (chosen fields) from having to agree on shape.
121
+ """
122
+ if _json:
123
+ _write(json.dumps(_envelope(kind, {'count': len(rows), 'items': rows})))
124
+ return
125
+ if _quiet:
126
+ return
127
+ if not rows:
128
+ if is_tty():
129
+ _write(f" {ui.Colors.DIM}(nothing to show){ui.Colors.RESET}")
130
+ return
131
+ if not is_tty():
132
+ # Piped: one path per line, so the next command can read it on stdin.
133
+ for row in rows:
134
+ _write(str(row.get(pipe_key, '')))
135
+ return
136
+ cells = cells or (lambda r: [str(v) for v in r.values()])
137
+ body = [cells(r) for r in rows]
138
+ eff = ui.get_terminal_width() - 2 * ui.MARGIN_H
139
+ widths = pc._table_widths(body, columns, eff, 0, 0)
140
+ for cell_row in body:
141
+ _write(pc._render_table_row(cell_row, columns, False, widths, eff, 0))
142
+
143
+
144
+ def event(kind: str, **fields) -> None:
145
+ """One step of a long operation: an NDJSON line, or a human progress line.
146
+
147
+ Flushed per event, so `tool run --json | jq` reports each file
148
+ as it happens rather than everything at the end.
149
+ """
150
+ if _json:
151
+ _write(json.dumps(_envelope('event', {'event': kind, **fields})))
152
+ return
153
+ if _quiet:
154
+ return
155
+ detail = fields.get('detail') or fields.get('path') or ''
156
+ mark = {'written': '✔', 'error': '✘'}.get(kind, '·')
157
+ _write(f" {mark} {detail}")
158
+
159
+
160
+ def note(text: str) -> None:
161
+ """An aside for a human: a count, a "nothing to do". Never emitted as JSON.
162
+
163
+ Anything a script needs belongs in a `record` or an `event`; this is the
164
+ sentence a person reads and a pipeline correctly ignores.
165
+ """
166
+ if not _json and not _quiet:
167
+ _write(f" {text}")
168
+
169
+
170
+ def fail(code: int, message: str, **context) -> int:
171
+ """Report a failure on stderr and return the exit code, for `return fail(…)`.
172
+
173
+ Under `--json` the shape is `{"error": {"code", "message", "context"}}`, with
174
+ `code` the symbolic name rather than the number so a consumer reads
175
+ `not_found` instead of remembering that 3 means that.
176
+ """
177
+ if _json:
178
+ body = _envelope('error', {'error': {
179
+ 'code': code_name(code), 'message': message, 'context': context}})
180
+ sys.stderr.write(json.dumps(body) + "\n")
181
+ else:
182
+ accent, reset = ui.Colors.ACCENT, ui.Colors.RESET
183
+ detail = ''.join(f"\n {k}: {v}" for k, v in context.items())
184
+ sys.stderr.write(f"{accent}✘{reset} {message}{detail}\n")
185
+ sys.stderr.flush()
186
+ return code
187
+
188
+
189
+ _CODE_NAMES = {OK: 'ok', FAIL: 'failed', USAGE: 'usage', NOT_FOUND: 'not_found',
190
+ EXISTS: 'exists', NO_TOOL: 'missing_tool'}
191
+
192
+
193
+ def code_name(code: int) -> str:
194
+ """The symbolic name for an exit code ('not_found' for 3)."""
195
+ return _CODE_NAMES.get(code, 'failed')
196
+
197
+
198
+ def read_stdin_paths() -> list[str]:
199
+ """Paths piped in on stdin, one per line, blank lines and comments dropped.
200
+
201
+ The other half of `table`'s piped output, so a list of tracks flows into a
202
+ command that takes tracks. Returns [] when stdin is a terminal, so a command
203
+ with no arguments prompts or errors rather than hanging on a read that will
204
+ never end.
205
+ """
206
+ try:
207
+ if sys.stdin.isatty():
208
+ return []
209
+ except (AttributeError, ValueError):
210
+ return []
211
+ out = []
212
+ for line in sys.stdin:
213
+ line = line.strip()
214
+ if line and not line.startswith('#'):
215
+ out.append(line)
216
+ return out
backbone/procs.py ADDED
@@ -0,0 +1,60 @@
1
+ """Background processes a tool starts, finds and stops: its daemons, however
2
+ they were launched."""
3
+ import os
4
+ import signal
5
+ import subprocess
6
+ import sys
7
+ from itertools import dropwhile
8
+ from pathlib import Path
9
+
10
+
11
+ def ps_listing() -> str:
12
+ """Every running process's full command line, one per line."""
13
+ try:
14
+ return subprocess.run(["ps", "-Awwo", "command"], capture_output=True, text=True, timeout=10).stdout
15
+ except (OSError, subprocess.TimeoutExpired):
16
+ return ""
17
+
18
+
19
+ def find_processes(*names: str, launcher: str | None = None) -> list:
20
+ """(pid, command) for every running process whose program is one of
21
+ `names`, however it was started: `name`, `name.py`, `python3 name.py`, or
22
+ through a `launcher` command or package (`launcher name`,
23
+ `python3 -m launcher.name`). The calling process is never included."""
24
+ try:
25
+ out = subprocess.run(["ps", "-Awwo", "pid=,command="], capture_output=True, text=True, timeout=10).stdout
26
+ except (OSError, subprocess.TimeoutExpired):
27
+ return []
28
+ found = []
29
+ for line in out.splitlines():
30
+ pid, _, command = line.strip().partition(" ")
31
+ if not pid.isdigit() or int(pid) == os.getpid():
32
+ continue
33
+ args = list(dropwhile(lambda a: a.startswith("-") or Path(a).name.lower().startswith("python"), command.split()))
34
+ if not args:
35
+ continue
36
+ prog = Path(args[0]).name.removesuffix(".py")
37
+ if launcher and prog.startswith(launcher + "."):
38
+ prog = prog[len(launcher) + 1:]
39
+ if prog in names or (launcher and prog == launcher and len(args) > 1 and args[1] in names):
40
+ found.append((int(pid), command))
41
+ return found
42
+
43
+
44
+ def stop_processes(*names: str, launcher: str | None = None) -> int:
45
+ """SIGTERMs every running process find_processes finds; returns how many."""
46
+ stopped = 0
47
+ for pid, _ in find_processes(*names, launcher=launcher):
48
+ try:
49
+ os.kill(pid, signal.SIGTERM)
50
+ stopped += 1
51
+ except OSError:
52
+ pass
53
+ return stopped
54
+
55
+
56
+ def spawn_module(module: str, out_path: Path) -> None:
57
+ """Run `python -m module` in the background with this interpreter, its
58
+ output (unbuffered, so it can be read while it runs) appended to `out_path`."""
59
+ with open(out_path, "ab") as out: # the child keeps its own copy of the handle
60
+ subprocess.Popen([sys.executable, "-u", "-m", module], stdout=out, stderr=subprocess.STDOUT)
@@ -0,0 +1,20 @@
1
+ """The prompt widgets, one import: lists (select, live_select, confirm,
2
+ ListPlace), text (text, path, system_editor_edit), list_edit, dates, values,
3
+ audio, and the shared chrome."""
4
+ from backbone.prompt.core import ( # noqa: F401
5
+ Choice, Column, separator, HINTS_CLICK, add_help_corner, add_hint_click_cells,
6
+ help_corner_text, help_toggle_width, hints_visible, is_hints_key, rounded_header, toggle_hints,
7
+ )
8
+ from backbone.prompt.chrome import ( # noqa: F401
9
+ CHROME_HANDLED, CHROME_REDRAW, MODE_TOGGLE, move_hint,
10
+ append_chrome, chrome_hint_lines, chrome_hint_pairs, consume_chrome,
11
+ disable_mouse, enable_mouse,
12
+ set_activity_opener, set_player_opener, set_transport_handler,
13
+ )
14
+ from backbone.prompt.text import path, system_editor_edit, text # noqa: F401
15
+ from backbone.prompt.lists import ListPlace, confirm, live_select, options_menu, select # noqa: F401
16
+ from backbone.prompt.list_edit import list_edit # noqa: F401
17
+ from backbone.prompt.dates import calendar_select, datetime_edit # noqa: F401
18
+ from backbone.prompt.values import fraction_edit, number_edit, rating_edit, time_edit # noqa: F401
19
+ from backbone.prompt.audio import equaliser_edit, rva2_edit # noqa: F401
20
+ from backbone.prompt.keymap import keys_editor # noqa: F401