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 ADDED
@@ -0,0 +1,4 @@
1
+ """pp: a CLI kit for user & agents. One file per subcommand, discovered at runtime."""
2
+
3
+ __version__ = "0.1.0"
4
+ TAGLINE = "a CLI kit for user & agents" # pp -h's header; README and pyproject say it too (tests/test_cli.py)
pp/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from pp.cli import main
4
+
5
+ sys.exit(main())
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 []
@@ -0,0 +1 @@
1
+ """Built-in subcommands: every module here with a docstring and main(argv) is a command named after its file."""