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/__init__.py +30 -0
- backbone/app.py +26 -0
- backbone/datetime_parse.py +224 -0
- backbone/deps.py +98 -0
- backbone/files.py +61 -0
- backbone/keyboard.py +148 -0
- backbone/keys.py +271 -0
- backbone/log.py +61 -0
- backbone/nav.py +17 -0
- backbone/notify.py +36 -0
- backbone/numbering.py +182 -0
- backbone/output.py +216 -0
- backbone/procs.py +60 -0
- backbone/prompt/__init__.py +20 -0
- backbone/prompt/audio.py +496 -0
- backbone/prompt/chrome.py +238 -0
- backbone/prompt/core.py +1562 -0
- backbone/prompt/dates.py +555 -0
- backbone/prompt/keymap.py +143 -0
- backbone/prompt/list_edit.py +974 -0
- backbone/prompt/lists.py +1280 -0
- backbone/prompt/text.py +356 -0
- backbone/prompt/timezone.py +1543 -0
- backbone/prompt/values.py +581 -0
- backbone/terminal_input.py +60 -0
- backbone/timefmt.py +34 -0
- backbone/ui.py +1044 -0
- backpack_backbone-0.2.0.dist-info/METADATA +177 -0
- backpack_backbone-0.2.0.dist-info/RECORD +32 -0
- backpack_backbone-0.2.0.dist-info/WHEEL +5 -0
- backpack_backbone-0.2.0.dist-info/licenses/LICENSE +21 -0
- backpack_backbone-0.2.0.dist-info/top_level.txt +1 -0
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
|