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/commands/cmd.py
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
"""Write commands, or add others' from a git repository
|
|
2
|
+
|
|
3
|
+
pp cmd new NAME pp/commands/NAME.py in this pp's source tree, with tests/test_NAME.py and
|
|
4
|
+
tests/test_journey_NAME.py (its help examples, run through the real CLI);
|
|
5
|
+
run from a promoted release, that is the tree it was released from
|
|
6
|
+
pp cmd new NAME --user ~/.config/pp/commands/NAME.py, on this machine only
|
|
7
|
+
pp cmd new NAME --user -s "One-line summary"
|
|
8
|
+
pp cmd add github.com/OWNER/REPO clone it, pin it to one commit, show what it adds, then ask
|
|
9
|
+
pp cmd add URL -n --show only show: its commands, and their code
|
|
10
|
+
pp cmd add URL --ref v1.2 --name tools
|
|
11
|
+
pp cmd update every plugin: fetch, show the diff, ask, then move the pin
|
|
12
|
+
pp cmd update tools -n only show what would change
|
|
13
|
+
pp cmd rm tools remove a plugin and its commands
|
|
14
|
+
pp cmd ls every command and where it comes from (--json)
|
|
15
|
+
|
|
16
|
+
A new command has a docstring whose first line is its summary, a main(argv), and only the standard library at
|
|
17
|
+
the top of the file (the rest: https://github.com/xflash96/pp/blob/main/CONTRIBUTING.md).
|
|
18
|
+
|
|
19
|
+
A plugin is a git repository of such command files: the *.py files in its commands/ folder, or at its top when
|
|
20
|
+
it has no commands/ folder. Each plugin is a clone in ~/.local/share/pp/plugins/NAME, checked out at the commit
|
|
21
|
+
you said yes to; only `pp cmd update` moves it. A plugin's command never hides a built-in or one of yours: add
|
|
22
|
+
and update refuse a name that is taken.
|
|
23
|
+
|
|
24
|
+
A plugin's code runs as you, with your files and keys. Read what add and update show before you say yes.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import shutil
|
|
32
|
+
import sys
|
|
33
|
+
import time
|
|
34
|
+
|
|
35
|
+
from pp import cli
|
|
36
|
+
from pp.lib import lock, paths, proc, ui
|
|
37
|
+
|
|
38
|
+
TEMPLATE = '''"""{summary}
|
|
39
|
+
|
|
40
|
+
pp {name} ARG what it does
|
|
41
|
+
pp {name} -n ARG show what it would do
|
|
42
|
+
"""
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
from pp.lib import ui
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main(argv: list[str]) -> int:
|
|
49
|
+
ap = ui.parser("{name}", __doc__)
|
|
50
|
+
ap.add_argument("arg")
|
|
51
|
+
ap.add_argument("-n", "--dry-run", action="store_true", help="show the plan only")
|
|
52
|
+
ap.add_argument("-y", "--yes", action="store_true", help="do not ask")
|
|
53
|
+
a = ap.parse_args(argv)
|
|
54
|
+
if a.dry_run:
|
|
55
|
+
print(f"would do: {{a.arg}}")
|
|
56
|
+
return 0
|
|
57
|
+
print(a.arg)
|
|
58
|
+
return 0
|
|
59
|
+
'''
|
|
60
|
+
|
|
61
|
+
TEST_TEMPLATE = '''"""Unit tests for `pp {name}`, in tests/sandbox.py's scope (CONTRIBUTING.md, Tests)."""
|
|
62
|
+
import io
|
|
63
|
+
import os
|
|
64
|
+
import sys
|
|
65
|
+
import unittest
|
|
66
|
+
from contextlib import redirect_stdout
|
|
67
|
+
|
|
68
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
69
|
+
|
|
70
|
+
import sandbox # noqa: E402
|
|
71
|
+
from pp.commands import {name} # noqa: E402
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Test{cls}(unittest.TestCase):
|
|
75
|
+
def setUp(self):
|
|
76
|
+
# the scope owns the PP_* directories, HOME, the cwd and the session identity, so this test reads and
|
|
77
|
+
# writes a tree of its own and never the machine's. First in setUp, so it closes last
|
|
78
|
+
self.sb = sandbox.enter(self)
|
|
79
|
+
|
|
80
|
+
def test_runs(self):
|
|
81
|
+
out = io.StringIO()
|
|
82
|
+
with redirect_stdout(out):
|
|
83
|
+
self.assertEqual({name}.main(["hello"]), 0)
|
|
84
|
+
self.assertIn("hello", out.getvalue())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
unittest.main()
|
|
89
|
+
'''
|
|
90
|
+
|
|
91
|
+
JOURNEY_TEMPLATE = '''"""Journeys through `pp {name}`: every example in its help, run the way a person would (tests/journey.py).
|
|
92
|
+
|
|
93
|
+
A step that uses a command other than this one is guarded with `needs` or `has`, imported from journey the
|
|
94
|
+
way `Journey` is, so a tree with that command removed still passes. A step that needs a state waits for it
|
|
95
|
+
with `self.until`.
|
|
96
|
+
"""
|
|
97
|
+
import os
|
|
98
|
+
import sys
|
|
99
|
+
import unittest
|
|
100
|
+
|
|
101
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
102
|
+
|
|
103
|
+
from journey import Journey # noqa: E402
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Test{cls}Journey(Journey):
|
|
107
|
+
def test_the_help_examples(self):
|
|
108
|
+
self.assertIn("usage: pp {name}", self.pp("{name}", "-h").stdout)
|
|
109
|
+
self.assertIn("required: arg", self.pp("{name}", code=2).stderr)
|
|
110
|
+
thing = self.write("work/thing.txt", "hello\\n") # a file in the journey's own directory
|
|
111
|
+
self.assertEqual(self.pp("{name}", "thing.txt").stdout, "thing.txt\\n")
|
|
112
|
+
self.assertEqual(self.pp("{name}", "-n", "thing.txt").stdout, "would do: thing.txt\\n")
|
|
113
|
+
self.assertEqual(self.read(thing), "hello\\n") # `-n` showed the plan and changed nothing
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
unittest.main()
|
|
118
|
+
'''
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---- new ------------------------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def cmd_new(argv: list[str]) -> int:
|
|
124
|
+
ap = ui.parser("cmd new", "Write a new command, with its tests.")
|
|
125
|
+
ap.add_argument("name")
|
|
126
|
+
ap.add_argument("-s", "--summary", default="TODO: one-line summary")
|
|
127
|
+
ap.add_argument("--user", action="store_true", help="write to ~/.config/pp/commands (machine-local)")
|
|
128
|
+
ap.add_argument("--no-test", action="store_true")
|
|
129
|
+
a = ap.parse_args(argv)
|
|
130
|
+
if not re.fullmatch(r"[a-z][a-z0-9_]*", a.name):
|
|
131
|
+
ui.die("a command name is lowercase letters, digits and _ (it is a Python module name)", 2)
|
|
132
|
+
taken = _taken(a.name)
|
|
133
|
+
if taken:
|
|
134
|
+
ui.die(f"'{a.name}' is taken: {taken}; pick another name", 2)
|
|
135
|
+
prog = os.path.basename(sys.argv[0]) or "pp"
|
|
136
|
+
if a.user:
|
|
137
|
+
d = os.path.join(paths.config_dir(), "commands")
|
|
138
|
+
test_dir = None
|
|
139
|
+
else:
|
|
140
|
+
root = _source_tree()
|
|
141
|
+
if root != paths.pp_root():
|
|
142
|
+
prog = "ppd"
|
|
143
|
+
d = os.path.join(root, "pp", "commands")
|
|
144
|
+
test_dir = None if a.no_test else os.path.join(root, "tests")
|
|
145
|
+
path = os.path.join(d, a.name + ".py")
|
|
146
|
+
os.makedirs(d, exist_ok=True)
|
|
147
|
+
try:
|
|
148
|
+
with open(path, "x") as f: # "x": the file is this writer's own, or there was one already
|
|
149
|
+
f.write(TEMPLATE.format(name=a.name, summary=a.summary))
|
|
150
|
+
except FileExistsError:
|
|
151
|
+
ui.die(f"{path} exists", 2)
|
|
152
|
+
print(f"wrote {path}")
|
|
153
|
+
if test_dir and os.path.isdir(test_dir):
|
|
154
|
+
cls = "".join(w.capitalize() for w in a.name.split("_"))
|
|
155
|
+
for tmpl, fname in ((TEST_TEMPLATE, f"test_{a.name}.py"), (JOURNEY_TEMPLATE, f"test_journey_{a.name}.py")):
|
|
156
|
+
tpath = os.path.join(test_dir, fname)
|
|
157
|
+
# each template is written only where what it imports is: a tests/ without them is a published
|
|
158
|
+
# subset or a contributor's own, and a test that cannot import is worse than no test
|
|
159
|
+
needed = "journey.py" if tmpl is JOURNEY_TEMPLATE else "sandbox.py"
|
|
160
|
+
if not os.path.exists(os.path.join(test_dir, needed)):
|
|
161
|
+
continue
|
|
162
|
+
try:
|
|
163
|
+
with open(tpath, "x") as f:
|
|
164
|
+
f.write(tmpl.format(name=a.name, cls=cls))
|
|
165
|
+
except FileExistsError: # a test file of that name is the user's: never overwritten
|
|
166
|
+
continue
|
|
167
|
+
print(f"wrote {tpath}")
|
|
168
|
+
print(f"try: {prog} {a.name} -h" + (" (`pp` gets it at the next `pp promote` of that tree)" if prog == "ppd" else ""))
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _taken(name: str, cmds: dict | None = None) -> str | None:
|
|
173
|
+
"""Why NAME cannot be a new command: an existing one would hide it, or it would hide an existing one."""
|
|
174
|
+
if name == "help":
|
|
175
|
+
return "`pp help CMD` is pp's own"
|
|
176
|
+
cmds = cli.discover() if cmds is None else cmds
|
|
177
|
+
if name in cmds:
|
|
178
|
+
c = cmds[name]
|
|
179
|
+
return f"{c.origin} command {c.path}"
|
|
180
|
+
owner = next((c for c in cmds.values() if name in c.aliases), None)
|
|
181
|
+
return f"an alias of '{owner.name}' ({owner.path})" if owner else None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _source_tree() -> str:
|
|
185
|
+
"""The tree to add a built-in to: this pp's own, unless this pp is a promoted release (a snapshot replaced at
|
|
186
|
+
the next promote), in which case the source it was released from. An installed package has no tree to add to."""
|
|
187
|
+
root = paths.pp_root()
|
|
188
|
+
if not os.path.isfile(os.path.join(root, "bin", "pp")):
|
|
189
|
+
ui.die(f"this pp is an installed package ({root}); a command written into it would vanish at the next "
|
|
190
|
+
"upgrade. `pp cmd new NAME --user` writes one of your own", 2)
|
|
191
|
+
meta = paths.release_meta(root)
|
|
192
|
+
if meta is None:
|
|
193
|
+
return root
|
|
194
|
+
src = meta.get("source")
|
|
195
|
+
if not src or not os.path.isdir(os.path.join(src, "pp", "commands")):
|
|
196
|
+
ui.die(f"this pp is a release ({root}) and its source tree ({src}) is gone; a command written here would "
|
|
197
|
+
"vanish at the next promote. `pp cmd new NAME --user` writes a machine-local one", 2)
|
|
198
|
+
return src
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---- plugins --------------------------------------------------------------------------------------
|
|
202
|
+
#
|
|
203
|
+
# ~/.local/share/pp/plugins/NAME/ is a clone, detached at the approved commit; cli.discover reads its commands
|
|
204
|
+
# (the plugin origin, after builtin and user). .index.json beside the clones records {NAME: {url, ref, commit,
|
|
205
|
+
# added, updated}}: where it came from and which ref `update` follows (null: the remote's default branch). Its
|
|
206
|
+
# leading dot keeps it out of discovery and out of the names a plugin can have, and so does .lock's.
|
|
207
|
+
#
|
|
208
|
+
# add, update and rm each hold the plugins directory's lock (_holding) for the part that must not interleave
|
|
209
|
+
# with another `pp cmd`: the checks that say a name is free, the install, checkout or removal they allow, and
|
|
210
|
+
# the record of it (_changing, a transaction on .index.json under that same lock). The lock is taken after the
|
|
211
|
+
# clone and the question, not around them, so one plugin's slow remote or an unanswered prompt never holds up
|
|
212
|
+
# another verb; what was decided before it is decided again inside it. Only the record's own scope defers a
|
|
213
|
+
# signal, so the work a person waits for is always theirs to stop.
|
|
214
|
+
|
|
215
|
+
NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _root(create: bool = False) -> str:
|
|
219
|
+
return paths.data_dir("plugins", create=create)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _index_file(create: bool = False) -> str:
|
|
223
|
+
return os.path.join(_root(create=create), ".index.json")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _index() -> dict:
|
|
227
|
+
"""What is installed, as it was last written. A verb that changes it goes through _changing()."""
|
|
228
|
+
try:
|
|
229
|
+
with open(_index_file()) as f:
|
|
230
|
+
data = json.load(f)
|
|
231
|
+
return data if isinstance(data, dict) else {}
|
|
232
|
+
except (OSError, json.JSONDecodeError):
|
|
233
|
+
return {}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _plugins_lock() -> str:
|
|
237
|
+
return os.path.join(_root(create=True), ".lock")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _holding():
|
|
241
|
+
"""The plugins directory's lock, which one `pp cmd` verb holds at a time, with signals left as they are: a
|
|
242
|
+
Ctrl-C ends the work in this scope at once. The slow part of a verb goes here, and _changing takes the same
|
|
243
|
+
lock inside it, since the thread holding it may take it again."""
|
|
244
|
+
return lock.locked(_plugins_lock())
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _changing():
|
|
248
|
+
"""The index read, changed and written back under the plugins directory's lock. Signals arriving in this
|
|
249
|
+
scope are delivered when it ends, so what the verb did and the record of it both land, and what the verb
|
|
250
|
+
says it did is printed in the scope. It holds no slow work, since Ctrl-C waits for it."""
|
|
251
|
+
return paths.json_txn(_index_file(create=True), lock=_plugins_lock(), indent=1, sort_keys=True)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _git(*args: str, group: str, cwd: str | None = None) -> str:
|
|
255
|
+
"""git, or the reason it could not be done: a plugin comes from a clone, so a failure here stops the verb.
|
|
256
|
+
group is proc.git's, and it is stated here at every run: "shared" for the two that talk to a remote, so ssh
|
|
257
|
+
can ask on the terminal; "own" for the reads and the checkout, which ask nothing."""
|
|
258
|
+
r = proc.git(*args, group=group, cwd=cwd)
|
|
259
|
+
if r.timed_out:
|
|
260
|
+
ui.die(f"git {args[0]} took more than {proc.GIT_WAIT / 60:g} minutes; nothing changed", 1)
|
|
261
|
+
if r.returncode is None:
|
|
262
|
+
ui.die("git is not installed; pp cmd add and update need it", 1)
|
|
263
|
+
if r.returncode:
|
|
264
|
+
ui.die(f"git {' '.join(args[:2])}: {r.stderr.strip() or 'failed'}", 1)
|
|
265
|
+
return r.stdout
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _url(spec: str) -> str:
|
|
269
|
+
"""github.com/OWNER/REPO means its https URL; a directory means its absolute path; anything else is given to
|
|
270
|
+
git as it is."""
|
|
271
|
+
if re.fullmatch(r"github\.com/[\w.-]+/[\w.-]+", spec):
|
|
272
|
+
return "https://" + spec
|
|
273
|
+
local = os.path.expanduser(spec)
|
|
274
|
+
return os.path.abspath(local) if os.path.isdir(local) else spec
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _commands_in(clone: str) -> list[tuple[str, str, str]]:
|
|
278
|
+
"""(name, path, summary) of each command file the clone provides."""
|
|
279
|
+
d = cli.command_dir(clone)
|
|
280
|
+
out = []
|
|
281
|
+
for fn in sorted(os.listdir(d)):
|
|
282
|
+
if cli.is_command_file(fn):
|
|
283
|
+
path = os.path.join(d, fn)
|
|
284
|
+
summary, _, err = cli._scan(path)
|
|
285
|
+
out.append((fn[:-3], path, err or summary))
|
|
286
|
+
return out
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _lines(path: str) -> int:
|
|
290
|
+
with open(path, "rb") as f:
|
|
291
|
+
return f.read().count(b"\n")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _names_at(clone: str, rev: str) -> list[str]:
|
|
295
|
+
"""The command names the clone provides at rev, read from git: nothing is checked out before a yes."""
|
|
296
|
+
top = [line.split("\t", 1) for line in _git("ls-tree", rev, cwd=clone, group="own").splitlines()]
|
|
297
|
+
sub = ["commands/"] if any(meta.split()[1] == "tree" and name == "commands" for meta, name in top) else []
|
|
298
|
+
listed = _git("ls-tree", "--name-only", rev, *sub, cwd=clone, group="own").splitlines()
|
|
299
|
+
files = [os.path.basename(f) for f in listed]
|
|
300
|
+
return [f[:-3] for f in files if cli.is_command_file(f)]
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _clashes(names: list[str], skip: str | None = None) -> list[str]:
|
|
304
|
+
"""Which of names are taken, leaving out the plugin being updated (skip)."""
|
|
305
|
+
mine = os.path.join(_root(), skip) + os.sep if skip else None
|
|
306
|
+
cmds = {n: c for n, c in cli.discover().items() if not (mine and c.path.startswith(mine))}
|
|
307
|
+
return [f"{name}: {why}" for name in names for why in [_taken(name, cmds)] if why]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _free(name: str, dest: str, index: dict) -> None:
|
|
311
|
+
"""Refuse unless the name and its directory are free. Asked before the clone, so a taken name costs no
|
|
312
|
+
download, and asked again inside the transaction, where a plugin that landed in between is a refusal
|
|
313
|
+
rather than a failed rename."""
|
|
314
|
+
if name in index:
|
|
315
|
+
ui.die(f"a plugin named {name} is there already; `pp cmd update {name}`, or --name", 2)
|
|
316
|
+
if os.path.exists(dest):
|
|
317
|
+
ui.die(f"{dest} is in the way and is not a plugin pp added; move it, or pass --name", 2)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _unclashed(staging: str) -> None:
|
|
321
|
+
"""Refuse unless every command the clone provides still has a free name. Same two moments as _free."""
|
|
322
|
+
clashes = _clashes([n for n, _, _ in _commands_in(staging)])
|
|
323
|
+
if clashes:
|
|
324
|
+
ui.die("these names are taken; nothing was added:\n " + "\n ".join(clashes), 2)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _show_commands(clone: str, show: bool) -> None:
|
|
328
|
+
"""What a plugin adds, as the user reads it before saying yes: printable() throughout, since the files are
|
|
329
|
+
someone else's and an escape sequence in them must not redraw what the user reads."""
|
|
330
|
+
for name, path, summary in _commands_in(clone):
|
|
331
|
+
print(ui.printable(f" {name:<12} {summary} ({os.path.relpath(path, clone)}, {_lines(path)} lines)"))
|
|
332
|
+
if show:
|
|
333
|
+
with open(path, errors="replace") as f:
|
|
334
|
+
for line in f:
|
|
335
|
+
print(" | " + ui.printable(line.rstrip("\n")))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _proceed(a, prompt: str) -> int | None:
|
|
339
|
+
"""None to go ahead; else the exit code (0 for -n, 2 for a refusal)."""
|
|
340
|
+
if a.dry_run:
|
|
341
|
+
print(ui.c("(dry run: nothing changed)", "dim"))
|
|
342
|
+
return 0
|
|
343
|
+
return None if ui.confirm(prompt, a.yes) else 2
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def cmd_add(argv: list[str]) -> int:
|
|
347
|
+
ap = ui.parser("cmd add", "Add a git repository of commands, pinned to one commit.")
|
|
348
|
+
ap.add_argument("url", help="a git URL or path, or github.com/OWNER/REPO")
|
|
349
|
+
ap.add_argument("--ref", help="a branch or tag to follow (default: the repository's default branch)")
|
|
350
|
+
ap.add_argument("--name", help="the plugin's name (default: the repository's)")
|
|
351
|
+
ap.add_argument("--show", action="store_true", help="print the code of every command it adds")
|
|
352
|
+
ap.add_argument("-n", "--dry-run", action="store_true", help="show what it adds, change nothing")
|
|
353
|
+
ap.add_argument("-y", "--yes", action="store_true", help="do not ask")
|
|
354
|
+
a = ap.parse_args(argv)
|
|
355
|
+
url = _url(a.url)
|
|
356
|
+
name = a.name or re.sub(r"\.git$", "", url.rstrip("/").rsplit("/", 1)[-1])
|
|
357
|
+
if not NAME_RE.fullmatch(name):
|
|
358
|
+
ui.die(f"'{name}' cannot name a plugin (letters, digits, _ . -); pass --name", 2)
|
|
359
|
+
dest = os.path.join(_root(), name)
|
|
360
|
+
_free(name, dest, _index())
|
|
361
|
+
if os.path.isdir(url) and not os.path.exists(os.path.join(url, ".git")):
|
|
362
|
+
ui.die(f"{url} is not a git repository; a plugin is a git repository of command files", 2)
|
|
363
|
+
staging = paths.staging(_root(create=True), name)
|
|
364
|
+
try:
|
|
365
|
+
_git("clone", "--quiet", *(["--branch", a.ref] if a.ref else []), "--", url, staging, group="shared")
|
|
366
|
+
commit = _git("rev-parse", "HEAD", cwd=staging, group="own").strip()
|
|
367
|
+
_git("checkout", "--quiet", "--detach", commit, cwd=staging, group="own")
|
|
368
|
+
if not _commands_in(staging):
|
|
369
|
+
ui.die(f"{url} has no command files (commands/*.py, or *.py at its top)", 2)
|
|
370
|
+
print(f"plugin {name} from {url}" + (f" ({a.ref})" if a.ref else ""))
|
|
371
|
+
print("commit " + _git("log", "-1", "--format=%h %ad %s", "--date=short", cwd=staging, group="own").strip())
|
|
372
|
+
print("adds")
|
|
373
|
+
_show_commands(staging, a.show)
|
|
374
|
+
_unclashed(staging)
|
|
375
|
+
if not a.show:
|
|
376
|
+
print(ui.c(f"its code runs as you: read it first (pp cmd add {a.url} -n --show)", "yellow"))
|
|
377
|
+
rc = _proceed(a, f"add plugin {name}?")
|
|
378
|
+
if rc is not None:
|
|
379
|
+
return rc
|
|
380
|
+
with _changing() as index: # the rename is instant, so it belongs in the record's own scope
|
|
381
|
+
_free(name, dest, index)
|
|
382
|
+
_unclashed(staging)
|
|
383
|
+
try:
|
|
384
|
+
os.rename(staging, dest)
|
|
385
|
+
except OSError as e: # something outside pp put it there: say so, do not trace back
|
|
386
|
+
ui.die(f"{name} could not be installed at {dest}: {e}", 1)
|
|
387
|
+
index[name] = {"url": url, "ref": a.ref, "commit": commit, "added": time.time(), "updated": time.time()}
|
|
388
|
+
print(f"added {name} at {commit[:7]}: " + ", ".join(n for n, _, _ in _commands_in(dest)))
|
|
389
|
+
finally:
|
|
390
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
391
|
+
return 0
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _plugin(name: str) -> tuple[str, dict]:
|
|
395
|
+
data = _index()
|
|
396
|
+
if name not in data or not os.path.isdir(os.path.join(_root(), name)):
|
|
397
|
+
ui.die(f"no plugin named {name} (pp cmd ls)", 2)
|
|
398
|
+
return os.path.join(_root(), name), data[name]
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _target(clone: str, ref: str | None) -> str:
|
|
402
|
+
"""The commit `update` would move to: the fetched branch, or the tag, or the remote's default branch."""
|
|
403
|
+
for rev in ([f"origin/{ref}", ref] if ref else ["origin/HEAD"]):
|
|
404
|
+
r = proc.git("rev-parse", "--verify", "--quiet", f"{rev}^{{commit}}", cwd=clone, group="own")
|
|
405
|
+
if r.returncode == 0:
|
|
406
|
+
return r.stdout.strip()
|
|
407
|
+
ui.die(f"{clone}: {ref or 'the default branch'} is not on the remote any more", 1)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def cmd_update(argv: list[str]) -> int:
|
|
411
|
+
ap = ui.parser("cmd update", "Fetch plugins, show what changed, and move each pin when you say yes.")
|
|
412
|
+
ap.add_argument("names", nargs="*", help="default: every plugin")
|
|
413
|
+
ap.add_argument("--show", action="store_true", help="print the whole diff, not just which files changed")
|
|
414
|
+
ap.add_argument("-n", "--dry-run", action="store_true", help="show what would change, change nothing")
|
|
415
|
+
ap.add_argument("-y", "--yes", action="store_true", help="do not ask")
|
|
416
|
+
a = ap.parse_args(argv)
|
|
417
|
+
names = a.names or sorted(_index())
|
|
418
|
+
if not names:
|
|
419
|
+
print("no plugins (pp cmd add URL)")
|
|
420
|
+
return 0
|
|
421
|
+
rc = 0
|
|
422
|
+
|
|
423
|
+
def taken(name: str, clone: str, new: str, old: str) -> bool:
|
|
424
|
+
"""Names the commit would bring that something else already has. Printed, not fatal: the other plugins
|
|
425
|
+
still update. Asked before the question and again under the lock."""
|
|
426
|
+
clashes = _clashes(_names_at(clone, new), skip=name)
|
|
427
|
+
if clashes:
|
|
428
|
+
print(f"{name}: these names are taken, so it stays at {old[:7]}:\n " + "\n ".join(clashes),
|
|
429
|
+
file=sys.stderr)
|
|
430
|
+
return bool(clashes)
|
|
431
|
+
|
|
432
|
+
for name in names:
|
|
433
|
+
clone, rec = _plugin(name)
|
|
434
|
+
try: # one plugin's remote being away must not keep the others from updating
|
|
435
|
+
_git("fetch", "--quiet", "--tags", "--force", "origin", cwd=clone, group="shared")
|
|
436
|
+
old, new = rec["commit"], _target(clone, rec.get("ref"))
|
|
437
|
+
except SystemExit:
|
|
438
|
+
print(f"{name}: could not fetch from {rec['url']}; it stays at {rec['commit'][:7]}", file=sys.stderr)
|
|
439
|
+
rc = max(rc, 1)
|
|
440
|
+
continue
|
|
441
|
+
if new == old:
|
|
442
|
+
print(f"{name}: up to date at {old[:7]}")
|
|
443
|
+
continue
|
|
444
|
+
local = _git("status", "--porcelain", "--untracked-files=no", cwd=clone, group="own")
|
|
445
|
+
if local.strip(): # e.g. __pycache__ is no change
|
|
446
|
+
print(f"{name}: {clone} has local changes, so it stays at {old[:7]}; discard them, or "
|
|
447
|
+
f"`pp cmd rm {name}` and add it again", file=sys.stderr)
|
|
448
|
+
rc = 2
|
|
449
|
+
continue
|
|
450
|
+
print(f"{name}: {old[:7]} -> {new[:7]}")
|
|
451
|
+
for out in (_git("log", "--format= %h %s", f"{old}..{new}", cwd=clone, group="own"),
|
|
452
|
+
_git("diff", "--patch" if a.show else "--stat", old, new, cwd=clone, group="own")):
|
|
453
|
+
print("\n".join(ui.printable(line) for line in out.splitlines()))
|
|
454
|
+
go = (2 if taken(name, clone, new, old) else 0 if a.dry_run
|
|
455
|
+
else None if ui.confirm(f"move {name} to {new[:7]}?", a.yes) else 2)
|
|
456
|
+
if go is not None:
|
|
457
|
+
rc = max(rc, go)
|
|
458
|
+
continue
|
|
459
|
+
with _holding():
|
|
460
|
+
if name not in _index(): # a `pp cmd rm` of it finished while this was asked
|
|
461
|
+
print(f"{name}: removed while this ran, so it stays as it is", file=sys.stderr)
|
|
462
|
+
rc = max(rc, 1)
|
|
463
|
+
continue
|
|
464
|
+
if taken(name, clone, new, old):
|
|
465
|
+
rc = 2
|
|
466
|
+
continue
|
|
467
|
+
# a Ctrl-C ends the checkout here. What it leaves is a clone at a commit the index does not record:
|
|
468
|
+
# the next `pp cmd update` of this plugin moves the pin there, or says the clone has local changes.
|
|
469
|
+
_git("checkout", "--quiet", "--detach", new, cwd=clone, group="own")
|
|
470
|
+
with _changing() as index:
|
|
471
|
+
index[name].update(commit=new, updated=time.time())
|
|
472
|
+
print(f"{name}: now at {new[:7]}")
|
|
473
|
+
if a.dry_run:
|
|
474
|
+
print(ui.c("(dry run: nothing changed)", "dim"))
|
|
475
|
+
return rc
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def cmd_rm(argv: list[str]) -> int:
|
|
479
|
+
ap = ui.parser("cmd rm", "Remove a plugin and its commands.")
|
|
480
|
+
ap.add_argument("name")
|
|
481
|
+
ap.add_argument("-n", "--dry-run", action="store_true", help="show what goes, change nothing")
|
|
482
|
+
ap.add_argument("-y", "--yes", action="store_true", help="do not ask")
|
|
483
|
+
a = ap.parse_args(argv)
|
|
484
|
+
index = _index()
|
|
485
|
+
if a.name not in index:
|
|
486
|
+
ui.die(f"no plugin named {a.name} (pp cmd ls)", 2)
|
|
487
|
+
clone, rec = os.path.join(_root(), a.name), index[a.name]
|
|
488
|
+
print(f"plugin {a.name} at {rec['commit'][:7]} from {rec['url']}")
|
|
489
|
+
print("removes " + (", ".join(n for n, _, _ in _commands_in(clone)) + f" ({clone})" if os.path.isdir(clone)
|
|
490
|
+
else f"its record alone: the clone {clone} is already gone"))
|
|
491
|
+
rc = _proceed(a, f"remove plugin {a.name}?")
|
|
492
|
+
if rc is not None:
|
|
493
|
+
return rc
|
|
494
|
+
with _holding():
|
|
495
|
+
if a.name not in _index(): # another `pp cmd rm` of it finished while this was asked
|
|
496
|
+
ui.die(f"no plugin named {a.name} (pp cmd ls)", 2)
|
|
497
|
+
try:
|
|
498
|
+
# a Ctrl-C ends the removal here. What it leaves is part of the clone, and the record with it, so
|
|
499
|
+
# the next `pp cmd rm` of this plugin removes the rest.
|
|
500
|
+
shutil.rmtree(clone)
|
|
501
|
+
except FileNotFoundError: # the clone is already gone; its record goes all the same
|
|
502
|
+
pass
|
|
503
|
+
with _changing() as record:
|
|
504
|
+
record.pop(a.name)
|
|
505
|
+
print(f"removed {a.name}")
|
|
506
|
+
return 0
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def cmd_ls(argv: list[str]) -> int:
|
|
510
|
+
ap = ui.parser("cmd ls", "Every command, and where it comes from.")
|
|
511
|
+
ap.add_argument("--json", action="store_true")
|
|
512
|
+
a = ap.parse_args(argv)
|
|
513
|
+
index, root = _index(), _root()
|
|
514
|
+
rows = []
|
|
515
|
+
for name, c in sorted(cli.discover().items()):
|
|
516
|
+
row = {"name": name, "origin": c.origin, "path": c.path, "summary": c.summary, "error": c.error}
|
|
517
|
+
if c.origin == "plugin":
|
|
518
|
+
plugin = os.path.relpath(c.path, root).split(os.sep)[0]
|
|
519
|
+
rec = index.get(plugin, {})
|
|
520
|
+
row.update(plugin=plugin, url=rec.get("url"), ref=rec.get("ref"), commit=rec.get("commit"))
|
|
521
|
+
rows.append(row)
|
|
522
|
+
if a.json:
|
|
523
|
+
ui.print_json(rows)
|
|
524
|
+
return 0
|
|
525
|
+
print(ui.table([[r["name"], r["origin"], f"{r['plugin']} at {(r['commit'] or '?')[:7]}"
|
|
526
|
+
+ (f" ({r['ref']})" if r.get("ref") else "") + f" from {r['url']}"
|
|
527
|
+
if r["origin"] == "plugin" else r["path"]] for r in rows], ["command", "from", "where"]))
|
|
528
|
+
return 0
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
VERBS = {"new": cmd_new, "add": cmd_add, "update": cmd_update, "rm": cmd_rm, "ls": cmd_ls}
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def main(argv: list[str]) -> int:
|
|
535
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
536
|
+
ui.parser("cmd", __doc__).print_help()
|
|
537
|
+
print("\nverbs: " + " ".join(VERBS))
|
|
538
|
+
return 0
|
|
539
|
+
verb = argv[0]
|
|
540
|
+
if verb not in VERBS:
|
|
541
|
+
cands = [v for v in VERBS if v.startswith(verb)]
|
|
542
|
+
if len(cands) != 1:
|
|
543
|
+
ui.die(f"unknown verb {verb!r}; one of: {', '.join(VERBS)}", 2)
|
|
544
|
+
verb = cands[0]
|
|
545
|
+
return VERBS[verb](argv[1:])
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Print a shell completion script (zsh or bash)
|
|
2
|
+
|
|
3
|
+
zsh, in ~/.zshrc after compinit: source <(pp completion zsh)
|
|
4
|
+
bash, in ~/.bashrc: source <(pp completion bash)
|
|
5
|
+
|
|
6
|
+
Completes command names, then each command's verbs and flags. Candidates are asked from pp itself, so new
|
|
7
|
+
commands of any origin appear without regenerating; pp reads them from the help text a command already has.
|
|
8
|
+
Anything else (and everything after `--`) completes file names. Covers `pp` and `ppd` (the dev tree).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pp.lib import ui
|
|
13
|
+
|
|
14
|
+
ZSH = r'''#compdef pp ppd
|
|
15
|
+
_pp() {
|
|
16
|
+
local -a cmds cands
|
|
17
|
+
local line
|
|
18
|
+
if (( CURRENT == 2 )); then
|
|
19
|
+
for line in ${(f)"$(${words[1]} --commands 2>/dev/null)"}; do
|
|
20
|
+
cmds+=("${${line%%$'\t'*}//:/\\:}:${line#*$'\t'}")
|
|
21
|
+
done
|
|
22
|
+
_describe -t commands 'pp command' cmds
|
|
23
|
+
return
|
|
24
|
+
fi
|
|
25
|
+
cands=(${(f)"$(${words[1]} --complete "${(@)words[2,CURRENT]}" 2>/dev/null)"})
|
|
26
|
+
if (( ${#cands} )); then
|
|
27
|
+
compadd -a cands
|
|
28
|
+
else
|
|
29
|
+
_files
|
|
30
|
+
fi
|
|
31
|
+
}
|
|
32
|
+
compdef _pp pp ppd
|
|
33
|
+
'''
|
|
34
|
+
|
|
35
|
+
BASH = r'''_pp() {
|
|
36
|
+
local IFS=$'\n' cur=${COMP_WORDS[COMP_CWORD]}
|
|
37
|
+
local -a cands
|
|
38
|
+
cands=($("${COMP_WORDS[0]}" --complete "${COMP_WORDS[@]:1:COMP_CWORD}" 2>/dev/null))
|
|
39
|
+
if [ ${#cands[@]} -gt 0 ]; then
|
|
40
|
+
COMPREPLY=($(compgen -W "${cands[*]}" -- "$cur"))
|
|
41
|
+
else
|
|
42
|
+
compopt -o filenames 2>/dev/null
|
|
43
|
+
COMPREPLY=($(compgen -f -- "$cur"))
|
|
44
|
+
fi
|
|
45
|
+
}
|
|
46
|
+
complete -F _pp pp ppd
|
|
47
|
+
'''
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def main(argv: list[str]) -> int:
|
|
51
|
+
ap = ui.parser("completion", __doc__)
|
|
52
|
+
ap.add_argument("shell", choices=["zsh", "bash"])
|
|
53
|
+
a = ap.parse_args(argv)
|
|
54
|
+
print(ZSH if a.shell == "zsh" else BASH, end="")
|
|
55
|
+
return 0
|