ppkit 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.
- pp/__init__.py +4 -0
- pp/__main__.py +5 -0
- pp/cli.py +363 -0
- pp/commands/__init__.py +1 -0
- pp/commands/batch.py +848 -0
- pp/commands/cmd.py +545 -0
- pp/commands/completion.py +55 -0
- pp/commands/http.py +1499 -0
- pp/commands/kill.py +293 -0
- pp/commands/promote.py +945 -0
- pp/commands/queue.py +243 -0
- pp/lib/__init__.py +1 -0
- pp/lib/config.py +110 -0
- pp/lib/jobq.py +3106 -0
- pp/lib/lock.py +338 -0
- pp/lib/paths.py +163 -0
- pp/lib/proc.py +706 -0
- pp/lib/session.py +31 -0
- pp/lib/ui.py +189 -0
- ppkit-0.1.0.dist-info/METADATA +330 -0
- ppkit-0.1.0.dist-info/RECORD +24 -0
- ppkit-0.1.0.dist-info/WHEEL +4 -0
- ppkit-0.1.0.dist-info/entry_points.txt +3 -0
- ppkit-0.1.0.dist-info/licenses/LICENSE +202 -0
pp/__init__.py
ADDED
pp/__main__.py
ADDED
pp/cli.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Discovery and dispatch. Knows nothing about any particular command.
|
|
2
|
+
|
|
3
|
+
A command is found in one of five places, first match wins (so a plugin cannot silently replace a built-in):
|
|
4
|
+
|
|
5
|
+
builtin pp/commands/<name>.py shipped with pp
|
|
6
|
+
user ~/.config/pp/commands/<name>.py this machine's own, never published
|
|
7
|
+
plugin ~/.local/share/pp/plugins/<plugin>/[commands/]<name>.py added by `pp cmd add`, pinned to a commit
|
|
8
|
+
path <dir>/<name>.py for dir in $PP_PATH (os.pathsep-separated)
|
|
9
|
+
external pp-<name> executable on $PATH any language, git-style
|
|
10
|
+
|
|
11
|
+
A Python command is a module whose docstring's first line is its summary and which defines main(argv) -> int.
|
|
12
|
+
Listing reads modules with `ast` and never imports them, so a command whose file is absent, unparsable or whose
|
|
13
|
+
imports fail cannot break `pp -h` or any other command. Publishing a subset of pp is deleting files.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ast
|
|
18
|
+
import contextlib
|
|
19
|
+
import difflib
|
|
20
|
+
import importlib
|
|
21
|
+
import importlib.util
|
|
22
|
+
import io
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
import traceback
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
|
|
29
|
+
from pp import TAGLINE, __version__
|
|
30
|
+
from pp.lib import config, paths, proc, ui
|
|
31
|
+
|
|
32
|
+
BUILTIN_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "commands")
|
|
33
|
+
ORIGINS = ("builtin", "user", "plugin", "path", "external")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Command:
|
|
38
|
+
name: str
|
|
39
|
+
origin: str
|
|
40
|
+
path: str
|
|
41
|
+
summary: str = ""
|
|
42
|
+
aliases: tuple = ()
|
|
43
|
+
error: str | None = None
|
|
44
|
+
shadows: list = field(default_factory=list) # same-named commands from later origins, hidden by this one
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _scan(path: str) -> tuple[str, tuple, str | None]:
|
|
48
|
+
"""(summary, aliases, error) of a command module. The module is parsed, never imported."""
|
|
49
|
+
try:
|
|
50
|
+
with open(path, encoding="utf-8") as f:
|
|
51
|
+
tree = ast.parse(f.read(), path)
|
|
52
|
+
except (OSError, SyntaxError, ValueError) as e:
|
|
53
|
+
return "", (), f"{type(e).__name__}: {e}"
|
|
54
|
+
doc = (ast.get_docstring(tree) or "").strip()
|
|
55
|
+
summary = doc.splitlines()[0] if doc else ""
|
|
56
|
+
aliases: tuple = ()
|
|
57
|
+
has_main = False
|
|
58
|
+
for node in tree.body:
|
|
59
|
+
names = [t.id for t in getattr(node, "targets", ()) if isinstance(t, ast.Name)]
|
|
60
|
+
if isinstance(node, ast.FunctionDef) and node.name == "main" or "main" in names:
|
|
61
|
+
has_main = True
|
|
62
|
+
elif isinstance(node, ast.ImportFrom) and any((a.asname or a.name) == "main" for a in node.names):
|
|
63
|
+
has_main = True # `from mytool.cli import main`: a thin wrapper around a package
|
|
64
|
+
elif "ALIASES" in names:
|
|
65
|
+
try:
|
|
66
|
+
v = ast.literal_eval(node.value)
|
|
67
|
+
except (ValueError, TypeError, SyntaxError):
|
|
68
|
+
continue
|
|
69
|
+
if isinstance(v, str):
|
|
70
|
+
v = [v] # ALIASES = "x" means one alias, not three single letters
|
|
71
|
+
if isinstance(v, (list, tuple)):
|
|
72
|
+
aliases = tuple(str(a) for a in v if a)
|
|
73
|
+
return summary, aliases, (None if has_main else "defines no main(argv)")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_command_file(fn: str) -> bool:
|
|
77
|
+
"""A command file's name: NAME.py, not starting with _ or ."""
|
|
78
|
+
return fn.endswith(".py") and not fn.startswith(("_", "."))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def command_dir(clone: str) -> str:
|
|
82
|
+
"""Where a plugin keeps its command files: its commands/ folder, or its top when it has none."""
|
|
83
|
+
d = os.path.join(clone, "commands")
|
|
84
|
+
return d if os.path.isdir(d) else clone
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _plugin_dirs() -> list[str]:
|
|
88
|
+
root = paths.data_dir("plugins", create=False)
|
|
89
|
+
try:
|
|
90
|
+
clones = [os.path.join(root, n) for n in sorted(os.listdir(root)) if not n.startswith(".")]
|
|
91
|
+
except OSError:
|
|
92
|
+
return []
|
|
93
|
+
return [command_dir(c) for c in clones if os.path.isdir(c)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _py_dirs() -> list[tuple[str, str]]:
|
|
97
|
+
dirs = [("builtin", BUILTIN_DIR), ("user", os.path.join(paths.config_dir(), "commands"))]
|
|
98
|
+
dirs += [("plugin", d) for d in _plugin_dirs()]
|
|
99
|
+
dirs += [("path", paths.expand(d)) for d in os.environ.get("PP_PATH", "").split(os.pathsep) if d]
|
|
100
|
+
return dirs
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def discover() -> dict[str, Command]:
|
|
104
|
+
found: dict[str, Command] = {}
|
|
105
|
+
|
|
106
|
+
def add(cmd: Command):
|
|
107
|
+
if cmd.name in found:
|
|
108
|
+
found[cmd.name].shadows.append(cmd)
|
|
109
|
+
else:
|
|
110
|
+
found[cmd.name] = cmd
|
|
111
|
+
|
|
112
|
+
for origin, d in _py_dirs():
|
|
113
|
+
try:
|
|
114
|
+
names = sorted(os.listdir(d))
|
|
115
|
+
except OSError:
|
|
116
|
+
continue
|
|
117
|
+
for fn in names:
|
|
118
|
+
if not is_command_file(fn):
|
|
119
|
+
continue
|
|
120
|
+
path = os.path.join(d, fn)
|
|
121
|
+
summary, aliases, err = _scan(path)
|
|
122
|
+
add(Command(fn[:-3], origin, path, summary, aliases, err))
|
|
123
|
+
for d in os.environ.get("PATH", "").split(os.pathsep):
|
|
124
|
+
try:
|
|
125
|
+
names = sorted(os.listdir(d or "."))
|
|
126
|
+
except OSError:
|
|
127
|
+
continue
|
|
128
|
+
for fn in names:
|
|
129
|
+
path = os.path.join(d, fn)
|
|
130
|
+
if fn.startswith("pp-") and len(fn) > 3 and os.access(path, os.X_OK) and not os.path.isdir(path):
|
|
131
|
+
add(Command(fn[3:], "external", path, "(external: " + path + ")"))
|
|
132
|
+
return found
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def find(name: str, cmds: dict[str, Command]) -> Command | list[str] | None:
|
|
136
|
+
"""Exact name, then alias, then unique prefix: the command; several prefix matches: their names; else None."""
|
|
137
|
+
if name in cmds:
|
|
138
|
+
return cmds[name]
|
|
139
|
+
for cmd in cmds.values():
|
|
140
|
+
if name in cmd.aliases:
|
|
141
|
+
return cmd
|
|
142
|
+
pre = sorted(n for n in cmds if name and n.startswith(name))
|
|
143
|
+
if len(pre) == 1:
|
|
144
|
+
return cmds[pre[0]]
|
|
145
|
+
return pre or None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def resolve(name: str, cmds: dict[str, Command]) -> Command:
|
|
149
|
+
"""find(), or exit with suggestions."""
|
|
150
|
+
got = find(name, cmds)
|
|
151
|
+
if isinstance(got, Command):
|
|
152
|
+
return got
|
|
153
|
+
if got:
|
|
154
|
+
ui.die(f"'{name}' is ambiguous: {', '.join(got)}", 2)
|
|
155
|
+
close = difflib.get_close_matches(name, list(cmds) + [a for c in cmds.values() for a in c.aliases], n=3, cutoff=0.6)
|
|
156
|
+
hint = f": did you mean {' or '.join(close)}?" if close else "; `pp -h` lists commands"
|
|
157
|
+
ui.die(f"no command '{name}'{hint}", 2)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _load(cmd: Command):
|
|
161
|
+
if cmd.origin == "builtin":
|
|
162
|
+
return importlib.import_module(f"pp.commands.{cmd.name}")
|
|
163
|
+
# Compiled from the file each time, never from a .pyc: a cached one is checked by size and mtime in whole
|
|
164
|
+
# seconds, so a file changed within the second it was cached, to the same size, would run its old code.
|
|
165
|
+
spec = importlib.util.spec_from_file_location(f"pp_{cmd.origin}_{cmd.name}", cmd.path)
|
|
166
|
+
mod = importlib.util.module_from_spec(spec)
|
|
167
|
+
sys.modules[spec.name] = mod
|
|
168
|
+
with open(cmd.path, "rb") as f:
|
|
169
|
+
exec(compile(f.read(), cmd.path, "exec"), mod.__dict__)
|
|
170
|
+
return mod
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def run(cmd: Command, argv: list[str]) -> int:
|
|
174
|
+
if cmd.origin == "external":
|
|
175
|
+
try:
|
|
176
|
+
proc.exec_replace(cmd.path, argv)
|
|
177
|
+
except OSError as e:
|
|
178
|
+
# the kernel will not run this file: no shebang, a shebang naming an interpreter that is not
|
|
179
|
+
# there, a format it cannot read. Every other broken command says what is wrong, so this one does
|
|
180
|
+
ui.die(f"command '{cmd.name}' ({cmd.path}) could not run: {e.strerror}")
|
|
181
|
+
if cmd.error:
|
|
182
|
+
ui.die(f"command '{cmd.name}' ({cmd.path}) is broken: {cmd.error}")
|
|
183
|
+
try:
|
|
184
|
+
mod = _load(cmd)
|
|
185
|
+
except Exception as e: # an import failure stays this command's problem
|
|
186
|
+
debug = os.environ.get("PP_DEBUG")
|
|
187
|
+
if debug:
|
|
188
|
+
traceback.print_exc()
|
|
189
|
+
hint = "" if debug else "; PP_DEBUG=1 for the traceback"
|
|
190
|
+
ui.die(f"command '{cmd.name}' failed to load: {type(e).__name__}: {e} ({cmd.path}{hint})")
|
|
191
|
+
rc = mod.main(argv)
|
|
192
|
+
if rc is None or isinstance(rc, int):
|
|
193
|
+
return int(rc or 0)
|
|
194
|
+
print(rc, file=sys.stderr) # like sys.exit("message"): a message is a failure
|
|
195
|
+
return 1
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def help_text(cmds: dict[str, Command], show_all: bool = False) -> str:
|
|
199
|
+
out = [f"{ui.c('pp', 'bold')} {__version__}: {TAGLINE}",
|
|
200
|
+
"",
|
|
201
|
+
"usage: pp <command> [args...] pp help <command> [verb] pp -h --all (where each command lives)",
|
|
202
|
+
""]
|
|
203
|
+
heads = {"builtin": "commands:", "user": f"user commands ({os.path.join(paths.config_dir(), 'commands')}):",
|
|
204
|
+
"plugin": f"plugins ({paths.data_dir('plugins', create=False)}):", "path": "PP_PATH commands:",
|
|
205
|
+
"external": "external (pp-* on PATH):"}
|
|
206
|
+
for origin in ORIGINS:
|
|
207
|
+
group = sorted((c for c in cmds.values() if c.origin == origin and not c.error), key=lambda c: c.name)
|
|
208
|
+
if not group:
|
|
209
|
+
continue
|
|
210
|
+
out.append(heads[origin])
|
|
211
|
+
rows = []
|
|
212
|
+
for c in group:
|
|
213
|
+
label = c.name + (f" ({', '.join(c.aliases)})" if c.aliases else "")
|
|
214
|
+
extra = f" [{c.path}]" if show_all else ""
|
|
215
|
+
if show_all and c.shadows:
|
|
216
|
+
extra += " shadows: " + ", ".join(f"{s.origin}:{s.path}" for s in c.shadows)
|
|
217
|
+
rows.append([ui.printable(label), ui.printable(c.summary) + extra])
|
|
218
|
+
out.append(ui.table(rows, indent=" "))
|
|
219
|
+
out.append("")
|
|
220
|
+
hidden = [s for c in cmds.values() for s in c.shadows if s.origin != "external"]
|
|
221
|
+
if hidden and not show_all: # a file someone wrote that can never run: say so where they look
|
|
222
|
+
out.append(ui.c("hidden by a same-named command found first (rename to use):", "yellow"))
|
|
223
|
+
out.append(ui.table([[h.name, f"{h.origin} [{h.path}]"] for h in sorted(hidden, key=lambda h: h.name)],
|
|
224
|
+
indent=" "))
|
|
225
|
+
out.append("")
|
|
226
|
+
broken = sorted((c for c in cmds.values() if c.error), key=lambda c: c.name)
|
|
227
|
+
if broken:
|
|
228
|
+
out.append(ui.c("broken (skipped):", "yellow"))
|
|
229
|
+
out.append(ui.table([[c.name, f"{c.error} [{c.path}]"] for c in broken], indent=" "))
|
|
230
|
+
out.append("")
|
|
231
|
+
out.append("Unique prefixes work (`pp pro` = `pp promote`). `pp cmd new NAME --user` writes one of your own.")
|
|
232
|
+
return "\n".join(out)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _cwd_gone() -> bool:
|
|
236
|
+
try:
|
|
237
|
+
os.getcwd()
|
|
238
|
+
return False
|
|
239
|
+
except FileNotFoundError:
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def main(argv: list[str] | None = None) -> int:
|
|
244
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
245
|
+
try:
|
|
246
|
+
return _main(argv)
|
|
247
|
+
except config.ConfigError as e:
|
|
248
|
+
ui.die(f"bad config: {e}")
|
|
249
|
+
except KeyboardInterrupt:
|
|
250
|
+
print(file=sys.stderr)
|
|
251
|
+
return 130
|
|
252
|
+
except BrokenPipeError: # `pp ... | head`
|
|
253
|
+
return 0
|
|
254
|
+
except FileNotFoundError:
|
|
255
|
+
if _cwd_gone():
|
|
256
|
+
ui.die("the current directory no longer exists; cd somewhere that does")
|
|
257
|
+
raise
|
|
258
|
+
finally:
|
|
259
|
+
# flush here, where a closed pipe is still ours to catch: at exit it becomes "Exception ignored" + exit 120
|
|
260
|
+
try:
|
|
261
|
+
sys.stdout.flush()
|
|
262
|
+
except BrokenPipeError:
|
|
263
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
264
|
+
except (OSError, ValueError):
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _main(argv: list[str]) -> int:
|
|
269
|
+
cmds = discover()
|
|
270
|
+
if not argv or set(argv) <= {"-h", "--help", "--all"}:
|
|
271
|
+
print(help_text(cmds, show_all="--all" in argv))
|
|
272
|
+
return 0
|
|
273
|
+
if argv[0] == "--commands": # for shell completion: name<TAB>summary, aliases included
|
|
274
|
+
for c in sorted(cmds.values(), key=lambda c: c.name):
|
|
275
|
+
if not c.error:
|
|
276
|
+
for n in (c.name, *c.aliases):
|
|
277
|
+
print(f"{n}\t{c.summary}")
|
|
278
|
+
return 0
|
|
279
|
+
if argv[0] == "--complete": # for shell completion: candidates for the last of the words after `pp`
|
|
280
|
+
print("\n".join(complete(argv[1:], cmds)))
|
|
281
|
+
return 0
|
|
282
|
+
if argv[0] in ("-V", "--version"):
|
|
283
|
+
print(f"pp {__version__} ({paths.pp_root()})")
|
|
284
|
+
return 0
|
|
285
|
+
if argv[0] == "help":
|
|
286
|
+
rest = [w for w in argv[1:] if w not in ("-h", "--help", "--all", "help")]
|
|
287
|
+
if not rest:
|
|
288
|
+
print(help_text(cmds, show_all="--all" in argv))
|
|
289
|
+
return 0
|
|
290
|
+
return run(resolve(rest[0], cmds), rest[1:] + ["--help"]) # `pp help queue add` = `pp queue add -h`
|
|
291
|
+
if argv[0].startswith("-"):
|
|
292
|
+
ui.die(f"unknown option {argv[0]}; `pp -h` for usage", 2)
|
|
293
|
+
return run(resolve(argv[0], cmds), argv[1:])
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---- completion -------------------------------------------------------------------------------------------
|
|
297
|
+
# Verbs and flags come from what a command already prints: its docstring's `pp CMD VERB ...` examples and
|
|
298
|
+
# argparse's option lines. No command carries completion code, and commands of every origin complete the same way. Only built-in
|
|
299
|
+
# commands are run (with --help, output captured); a plugin's docstring is read without running anything, since
|
|
300
|
+
# pressing TAB must never execute someone's half-written script.
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _help_of(cmd: Command, argv: list[str]) -> str:
|
|
304
|
+
if cmd.origin != "builtin":
|
|
305
|
+
if argv or cmd.error or cmd.origin == "external":
|
|
306
|
+
return ""
|
|
307
|
+
try:
|
|
308
|
+
with open(cmd.path, encoding="utf-8") as f:
|
|
309
|
+
return ast.get_docstring(ast.parse(f.read())) or ""
|
|
310
|
+
except (OSError, SyntaxError, ValueError):
|
|
311
|
+
return ""
|
|
312
|
+
out, err = io.StringIO(), io.StringIO()
|
|
313
|
+
try:
|
|
314
|
+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
|
315
|
+
_load(cmd).main(argv + ["--help"])
|
|
316
|
+
except BaseException: # SystemExit from argparse is the normal end; anything else just means no help
|
|
317
|
+
pass
|
|
318
|
+
return out.getvalue()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _verbs(name: str, text: str) -> list[str]:
|
|
322
|
+
"""Verbs: every `pp NAME VERB` in the examples, and argparse sub-commands (a 4-space indented word in the
|
|
323
|
+
positional-arguments section)."""
|
|
324
|
+
found = re.findall(rf"\bpp {re.escape(name)} ([a-z][a-z0-9-]*)\b(?![./])", text)
|
|
325
|
+
if "positional arguments:" in text:
|
|
326
|
+
section = text.split("positional arguments:", 1)[1].split("\noptions:", 1)[0]
|
|
327
|
+
found += re.findall(r"^ ([a-z][a-z0-9-]*)(?:\s{2,}|$)", section, re.M)
|
|
328
|
+
return sorted(set(found))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _flags(text: str) -> list[str]:
|
|
332
|
+
"""Flags from argparse's options section: ` -p PORT, --port PORT help` -> -p, --port."""
|
|
333
|
+
flags = []
|
|
334
|
+
for line in text.rsplit("\noptions:", 1)[-1].splitlines() if "\noptions:" in text else ():
|
|
335
|
+
if line.startswith(" ") and line.lstrip().startswith("-"):
|
|
336
|
+
flags += [part.split()[0] for part in line.strip().split(" ")[0].split(", ")]
|
|
337
|
+
return sorted(set(flags))
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def complete(words: list[str], cmds: dict[str, Command]) -> list[str]:
|
|
341
|
+
"""Candidates for words[-1] (possibly "") in `pp WORDS...`; none means: complete file names."""
|
|
342
|
+
*before, cur = words or [""]
|
|
343
|
+
if before[:1] == ["help"]:
|
|
344
|
+
before = before[1:]
|
|
345
|
+
if not before:
|
|
346
|
+
names = {n for c in cmds.values() if not c.error for n in (c.name, *c.aliases)}
|
|
347
|
+
if words[:1] != ["help"]:
|
|
348
|
+
names.add("help")
|
|
349
|
+
return sorted(n for n in names if n.startswith(cur))
|
|
350
|
+
if "--" in before: # past `--`: the rest is someone else's command line
|
|
351
|
+
return []
|
|
352
|
+
cmd = find(before[0], cmds)
|
|
353
|
+
if not isinstance(cmd, Command):
|
|
354
|
+
return []
|
|
355
|
+
text = _help_of(cmd, [])
|
|
356
|
+
verbs = _verbs(cmd.name, text)
|
|
357
|
+
verb = next((w for w in before[1:] if w in verbs), None)
|
|
358
|
+
if cur.startswith("-"):
|
|
359
|
+
flags = _flags(_help_of(cmd, [verb]) if verb else text) or re.findall(r"(?<![\w-])(--?[a-zA-Z][\w-]*)", text)
|
|
360
|
+
return sorted({f for f in flags if f.startswith(cur)})
|
|
361
|
+
if verb is None and not any(not w.startswith("-") for w in before[1:]):
|
|
362
|
+
return [v for v in verbs if v.startswith(cur)]
|
|
363
|
+
return []
|
pp/commands/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Built-in subcommands: every module here with a docstring and main(argv) is a command named after its file."""
|