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/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