ppkit 0.2.0__tar.gz → 0.2.2__tar.gz

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.
Files changed (56) hide show
  1. {ppkit-0.2.0 → ppkit-0.2.2}/CHANGELOG.md +136 -0
  2. {ppkit-0.2.0 → ppkit-0.2.2}/CONTRIBUTING.md +17 -9
  3. {ppkit-0.2.0 → ppkit-0.2.2}/PKG-INFO +26 -7
  4. {ppkit-0.2.0 → ppkit-0.2.2}/README.md +25 -6
  5. {ppkit-0.2.0 → ppkit-0.2.2}/pp/__init__.py +1 -1
  6. {ppkit-0.2.0 → ppkit-0.2.2}/pp/cli.py +120 -10
  7. {ppkit-0.2.0 → ppkit-0.2.2}/pp/commands/cmd.py +227 -6
  8. {ppkit-0.2.0 → ppkit-0.2.2}/pp/commands/queue.py +102 -40
  9. {ppkit-0.2.0 → ppkit-0.2.2}/pp/lib/config.py +10 -2
  10. {ppkit-0.2.0 → ppkit-0.2.2}/pp/lib/jobcore.py +27 -21
  11. {ppkit-0.2.0 → ppkit-0.2.2}/pp/lib/jobpolicy.py +112 -36
  12. {ppkit-0.2.0 → ppkit-0.2.2}/pp/lib/jobq.py +183 -74
  13. {ppkit-0.2.0 → ppkit-0.2.2}/pp/lib/proc.py +1 -1
  14. ppkit-0.2.2/pp/lib/session.py +97 -0
  15. {ppkit-0.2.0 → ppkit-0.2.2}/pyproject.toml +1 -1
  16. {ppkit-0.2.0 → ppkit-0.2.2}/tests/journey.py +11 -0
  17. {ppkit-0.2.0 → ppkit-0.2.2}/tests/sandbox.py +5 -1
  18. {ppkit-0.2.0 → ppkit-0.2.2}/tests/test_cli.py +19 -1
  19. {ppkit-0.2.0 → ppkit-0.2.2}/tests/test_cmd.py +55 -0
  20. {ppkit-0.2.0 → ppkit-0.2.2}/tests/test_journey_cli.py +72 -7
  21. {ppkit-0.2.0 → ppkit-0.2.2}/tests/test_journey_cmd.py +108 -4
  22. {ppkit-0.2.0 → ppkit-0.2.2}/tests/test_journey_kill.py +4 -1
  23. {ppkit-0.2.0 → ppkit-0.2.2}/tests/test_journey_queue.py +137 -9
  24. {ppkit-0.2.0 → ppkit-0.2.2}/tests/test_queue.py +499 -91
  25. ppkit-0.2.2/tests/test_session.py +65 -0
  26. ppkit-0.2.0/pp/lib/session.py +0 -31
  27. ppkit-0.2.0/tests/test_session.py +0 -24
  28. {ppkit-0.2.0 → ppkit-0.2.2}/.pp.toml +0 -0
  29. {ppkit-0.2.0 → ppkit-0.2.2}/LICENSE +0 -0
  30. {ppkit-0.2.0 → ppkit-0.2.2}/bin/pp +0 -0
  31. {ppkit-0.2.0 → ppkit-0.2.2}/ci/check +0 -0
  32. {ppkit-0.2.0 → ppkit-0.2.2}/ci/package +0 -0
  33. {ppkit-0.2.0 → ppkit-0.2.2}/pp/__main__.py +0 -0
  34. {ppkit-0.2.0 → ppkit-0.2.2}/pp/commands/__init__.py +0 -0
  35. {ppkit-0.2.0 → ppkit-0.2.2}/pp/commands/batch.py +0 -0
  36. {ppkit-0.2.0 → ppkit-0.2.2}/pp/commands/completion.py +0 -0
  37. {ppkit-0.2.0 → ppkit-0.2.2}/pp/commands/http.py +0 -0
  38. {ppkit-0.2.0 → ppkit-0.2.2}/pp/commands/kill.py +0 -0
  39. {ppkit-0.2.0 → ppkit-0.2.2}/pp/commands/promote.py +0 -0
  40. {ppkit-0.2.0 → ppkit-0.2.2}/pp/lib/__init__.py +0 -0
  41. {ppkit-0.2.0 → ppkit-0.2.2}/pp/lib/jobstore.py +0 -0
  42. {ppkit-0.2.0 → ppkit-0.2.2}/pp/lib/lock.py +0 -0
  43. {ppkit-0.2.0 → ppkit-0.2.2}/pp/lib/paths.py +0 -0
  44. {ppkit-0.2.0 → ppkit-0.2.2}/pp/lib/ui.py +0 -0
  45. {ppkit-0.2.0 → ppkit-0.2.2}/tests/test_batch.py +0 -0
  46. {ppkit-0.2.0 → ppkit-0.2.2}/tests/test_constructs.py +0 -0
  47. {ppkit-0.2.0 → ppkit-0.2.2}/tests/test_http.py +0 -0
  48. {ppkit-0.2.0 → ppkit-0.2.2}/tests/test_journey_batch.py +0 -0
  49. {ppkit-0.2.0 → ppkit-0.2.2}/tests/test_journey_http.py +0 -0
  50. {ppkit-0.2.0 → ppkit-0.2.2}/tests/test_journey_promote.py +0 -0
  51. {ppkit-0.2.0 → ppkit-0.2.2}/tests/test_kill.py +0 -0
  52. {ppkit-0.2.0 → ppkit-0.2.2}/tests/test_lock.py +0 -0
  53. {ppkit-0.2.0 → ppkit-0.2.2}/tests/test_paths.py +0 -0
  54. {ppkit-0.2.0 → ppkit-0.2.2}/tests/test_proc.py +0 -0
  55. {ppkit-0.2.0 → ppkit-0.2.2}/tests/test_promote.py +0 -0
  56. {ppkit-0.2.0 → ppkit-0.2.2}/tests/test_ui.py +0 -0
@@ -1,5 +1,141 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.2
4
+
5
+ `pp queue` records a kill as a kill when the worker sending it dies before
6
+ it records the send. Such a job no longer ends as its own failure, and its
7
+ `on_fail` hook does not run. A stop's new `going` field says when pp began
8
+ sending it.
9
+
10
+ `pp queue`: a kill whose worker dies while sending it is still a kill.
11
+
12
+ - A worker that died between sending a kill's stop and recording it left the
13
+ next worker to read the job's end as the job's own. That job was recorded
14
+ as its own failure, with exit code 137 or reason `lost`, and its `on_fail`
15
+ hook ran. It is now recorded `killed`, and no hook runs. A timeout's stop
16
+ in the same window now ends as a timeout.
17
+ - A stop gains `going` in `show --json` and `status --json`: when pp began
18
+ sending it, recorded before anything goes out. An exit code the attempt
19
+ wrote after that time is the stop's outcome, with the code kept, unless pp
20
+ was refused the stop. A job that ends on its own just as its worker dies
21
+ before sending is recorded the same way.
22
+ - `show` says "sending began" for a stop pp began sending and has nothing
23
+ marked sent for. A stop pp was refused still says "nothing signalled".
24
+ - `kill` reports a job that ended in that window as killed, "ended as its
25
+ stop was being sent", instead of saying the job had already ended.
26
+
27
+ ## v0.2.1
28
+
29
+ pp finds the session that runs it with nothing set, so `--mine` works for
30
+ any agent and in a terminal. `pp queue` reads the cards with a command of
31
+ yours, and any command answers to an alias of your own. A `pp-NAME` program
32
+ is a command only in pp's own folders, and no longer on your `PATH`.
33
+
34
+ `pp`: a program named `pp-NAME` on your `PATH` is no longer a command.
35
+
36
+ - pp looks for such a program in `~/.config/pp/commands/`, in each plugin
37
+ and in each `PP_PATH` folder, and reads no `PATH` for one. Other software
38
+ installs programs named `pp-*`, and on `PATH` such a program takes a
39
+ command's name or a unique prefix of it (`pp q`) and shows in `pp -h`. A
40
+ program of yours stays a command by moving it into
41
+ `~/.config/pp/commands/`, or by listing the folder it is in in `PP_PATH`.
42
+ - Every Python command is still found before any such program, and the
43
+ programs in that folder order, so nothing pp found in those folders
44
+ moves.
45
+
46
+ `pp queue`: the card reading and the calling session are named by you, with
47
+ nvidia-smi and the process pp runs under as the defaults.
48
+
49
+ - `[resources] gpu_gb = { probe = "CMD" }` reads the machine's cards with a
50
+ command of yours instead of nvidia-smi. CMD prints one line a card,
51
+ `index,name,total_MiB,used_MiB,util`, which is nvidia-smi's own
52
+ `--format=csv,noheader,nounits` line, and the index it prints is the one pp
53
+ reserves and publishes in `CUDA_VISIBLE_DEVICES`. The reading is cached for
54
+ the pool's `every` and taken under the probe's own claim, as any probe's is.
55
+ A probe in a `[queue.NAME.resources] gpu_gb` is still refused: one machine
56
+ has one set of cards.
57
+ - `pp queue status --json` gains `cards_why`: why `cards` is null, and `""`
58
+ where the cards were read.
59
+ - pp finds the calling session from the process it runs under: the nearest
60
+ process above pp that is neither a shell nor a wrapper, named with its pid,
61
+ its start time and this boot. So `--mine` and a job's `owner` are any
62
+ agent's with nothing set, since every command an agent runs is a child of
63
+ the agent's own process, and a person's terminal is a session the same way.
64
+ `PP_SESSION`, where it is set and not empty, is the name an agent gives its
65
+ own session instead, and wins over `CLAUDE_CODE_SESSION_ID`. A process pp
66
+ started itself, a job or its runner or a hook or a stop command, is no
67
+ session. pid 1 and an init are no session either, so a `wait` left running
68
+ by a shell that has returned, which the kernel reparents to an init, says
69
+ there is none instead of answering for another session's jobs. The session
70
+ is found once in a run, so such a wait reports the outcomes it selected to
71
+ the session that queued them and no later `wait --mine` of that session
72
+ reports them again. pp strips those variables from everything it starts.
73
+ - `pp queue status --mine`, which `pp queue --mine` and `ls --mine` are too,
74
+ is the jobs this session queued, in every state, in the text view and in
75
+ `--json`'s `running`, `queued`, `held` and `recent`. It applies together
76
+ with `--here` and `--project`, and narrows the jobs shown and not what the
77
+ queues, the workers and the pools hold. Where there is no session it exits
78
+ 2, as `wait --mine` does.
79
+
80
+ `pp`: a command answers to a name of your own.
81
+
82
+ - `[alias]` in `~/.config/pp/config.toml` names any command anything on this
83
+ machine: `st = "promote status"`, or a list of words. A word is resolved
84
+ as a command's own name, then `[alias]`, then a module's `ALIASES`, then a
85
+ unique prefix, so an alias hides no command and beats a prefix. `pp help
86
+ st` and `pp st <TAB>` follow the expansion.
87
+ - An alias runs no other alias. One whose key is a command's name or `help`,
88
+ or outside `[a-z][a-z0-9_-]*`, or whose first word names no command, is
89
+ skipped and listed under `pp -h`'s "broken (skipped)". The table is read
90
+ only where a word is not a command's exact name, so a config file that
91
+ cannot be parsed leaves every command pp ships working.
92
+ - `pp cmd alias` lists every alias of both kinds, with what it runs and
93
+ where it is declared (`--json` for the same as data). `pp cmd alias NAME
94
+ CMD [ARGS...]` adds one, `pp cmd alias NAME --rm` takes it back, and `-n`
95
+ prints the change and writes nothing. Those flags count wherever they
96
+ stand, so `pp cmd alias st promote status -n` is a dry run. A command line
97
+ of its own `-n`, `--rm` or `--json` goes after a `--`: `pp cmd alias dq --
98
+ queue drop -n`.
99
+ - `pp cmd alias` is the one place pp writes `config.toml`: one line of the
100
+ `[alias]` table under a lock, the file's mode kept, every other byte as it
101
+ was. A change it cannot make as one line names the file to edit by hand.
102
+ - `pp cmd ls` shows each command's aliases of both kinds, and its `--json`
103
+ rows gain `config_aliases` beside `aliases`.
104
+
105
+ `pp queue`: the views, the plans and the help say more of what pp knows
106
+ about your cards, your queues and your projects.
107
+
108
+ - `status --json` publishes the gpu_gb pool of each scope in `pools` and
109
+ `shared`, an entry like every other pool's with one field of its own:
110
+ `per_card`, that scope's budget on each card of the reading and what its
111
+ running jobs reserve on that card. The text view shows those numbers card
112
+ by card. So a job is sized against the smallest budget of every scope its
113
+ queue draws from before it is queued, instead of being refused first.
114
+ - `pp queue` covers every queue a `[queue.NAME]` table names, beside the
115
+ queues that hold records and the ones a worker is up for. Such a queue
116
+ with nothing in it is a section with nothing in it and an entry in
117
+ `--json`, and reading it creates no directory and no file. "no queues yet"
118
+ is for a machine with none of the three.
119
+ - `pp queue add` warns on stderr where the job has no runner, no `.pp.toml`
120
+ is in or above its directory, and a directory directly below is a project
121
+ whose `[queue]` sets one. The warning names that directory and `--cwd`,
122
+ once per directory, and the job is queued as it asks.
123
+ - The command `add` hands an agent names the ids it queued as ranges (`wait
124
+ 8-13 20`), which is one word for a campaign of a hundred lines.
125
+ - `clean` and `drop` say in their plan that they remove the records and pp's
126
+ own files for them, and that a log at a path a `log` pattern or `--log`
127
+ named stays.
128
+ - The extra run of the project's stop command once a run is over writes to
129
+ the worker's log instead of the job's log. The stop a kill or a timeout
130
+ asked for still writes in the job's log.
131
+ - `pp help queue` names the shape of each `--json`: one object with its keys
132
+ for `status`, one job and its watchers for `show`, and a list of `{queue,
133
+ pid, how, log, up}` for `worker status`.
134
+ - `pp help queue` says that several selection forms are several words on one
135
+ verb, that a tag or a prefix selects every job of that name the store
136
+ holds, an earlier round's ended ones too, and that a campaign line's
137
+ `--cwd` is that line's own directory and so its project.
138
+
3
139
  ## v0.2.0
4
140
 
5
141
  The interface this minor version keeps. `pp help queue` says what is promised
@@ -87,7 +87,9 @@ by running both.
87
87
  2. `def main(argv: list[str]) -> int`, which builds its parser with
88
88
  `ui.parser("NAME", __doc__)`.
89
89
  3. If you want short names for it, `ALIASES = ["x"]`, written as a literal
90
- list.
90
+ list. A name one machine wants is `[alias]` in its own `config.toml`
91
+ instead (`pp cmd alias x NAME`), which beats both `ALIASES` and a
92
+ prefix and is checked against the command names before it is written.
91
93
 
92
94
  These rules keep pp working as commands come and go:
93
95
 
@@ -183,7 +185,9 @@ These rules keep pp working as commands come and go:
183
185
  | 3 | `pp queue wait` or `watch` gave up at its `--timeout` |
184
186
  | 130 | interrupted (Ctrl-C) |
185
187
 
186
- A `pp-NAME` program's own exit status passes through unchanged.
188
+ A `pp-NAME` program in `~/.config/pp/commands/`, in a plugin or in a
189
+ `PP_PATH` folder is a command, and its own exit status passes through
190
+ unchanged. `PATH` is not read for one.
187
191
 
188
192
  ## How pp works
189
193
 
@@ -283,7 +287,7 @@ and `tests/test_constructs.py` reads the source for each:
283
287
  every write is one call and one whole transaction. Two sites outside it run a
284
288
  process of their own, and both are named exemptions: `reading`, which runs a
285
289
  user's probe under that probe's own claim, and `_cards`, which runs
286
- nvidia-smi.
290
+ nvidia-smi where `[resources] gpu_gb` gives no probe of its own.
287
291
  - The purity rule. A function of `jobpolicy.py` reaches no clock, no store, no
288
292
  process, no signal and no file. It is handed records, readings, settings and
289
293
  `now`, and gives back a value, so a rule that could be raced is a rule no
@@ -347,8 +351,9 @@ A kill, pass by pass:
347
351
 
348
352
  ```
349
353
  kill: ask_stop(merge_stop) -> worker_up(wake=True) -> SIGUSR1 to the worker
350
- worker: carry_out: the facts -> decide = TERM -> the project's stop command,
351
- or the SIGTERM to the group, then txn marks `sent`
354
+ worker: carry_out: the facts -> decide = TERM, and its txn records `going`
355
+ -> the project's stop command, or the SIGTERM to the group,
356
+ then txn marks `sent`
352
357
  -> (the grace) -> decide = KILL9 -> signal
353
358
  -> the wrapper ends, so the job's run claim is free
354
359
  -> decide = ENDED_AS -> _finish records the outcome and the hook it owes
@@ -363,11 +368,14 @@ own exit time and not the queue's poll period.
363
368
 
364
369
  The rules the worker follows are the ones `pp help queue` states under "How a
365
370
  job ends, whatever reached it", and that help text is their one statement.
366
- Two more are for contributors: which of the two a stop is comes from the
371
+ Three more are for contributors. Which of the two a stop is comes from the
367
372
  `timeout` field a worker writes on the stop it wrote itself, never from the
368
- name of whoever asked; and an exit code the attempt wrote after its stop went
369
- out is that stop's outcome, with the code kept, since a container runner writes
370
- the code of a container the stop command ended.
373
+ name of whoever asked. An exit code the attempt wrote after its stop began
374
+ going out is that stop's outcome, with the code kept, since a container runner
375
+ writes the code of a container the stop command ended. And that moment is the
376
+ stop's `going`, which the decision's transaction records with its own clock
377
+ before anything goes out: a worker can die between a signal and its record,
378
+ and a job can end between its facts and its decision.
371
379
 
372
380
  `TestTheDecision` in `tests/test_queue.py` is a table over every combination
373
381
  of those facts. Each row carries the rule from that help as its own label,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ppkit
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: A CLI kit for user & agents
5
5
  Keywords: cli,job queue,gpu,agents,toolkit
6
6
  Author: The pp Authors
@@ -61,12 +61,16 @@ pp needs no packages beyond Python itself, and it completes in zsh and bash.
61
61
 
62
62
  Each command is one Python file. pp looks for them when it runs: its own in
63
63
  `pp/commands/`, yours in `~/.config/pp/commands/`, the plugins you added
64
- with `pp cmd add`, the folders listed in `PP_PATH`, and any program named
65
- `pp-NAME` on your `PATH`. The first one found wins, so a plugin cannot hide
66
- a built-in or one of yours. `pp` lists commands without loading them, so
67
- one broken command cannot break the others.
64
+ with `pp cmd add`, and the folders listed in `PP_PATH`. A program named
65
+ `pp-NAME` in one of those folders, pp's own aside, is a command too. The
66
+ first one found wins, so a plugin cannot hide a built-in or one of yours.
67
+ `pp` lists commands without loading them, so one broken command cannot
68
+ break the others.
68
69
  `pp help NAME` prints a command's manual, with examples.
69
70
 
71
+ A command answers to its file's name, to a unique prefix of it, and to any
72
+ name you give it with `pp cmd alias`.
73
+
70
74
  Everything pp keeps is in plain files:
71
75
 
72
76
  ```
@@ -326,12 +330,27 @@ pp cmd rm REPO
326
330
  pp cmd ls every command, and where it comes from
327
331
  ```
328
332
 
333
+ Any command can have a name of your own:
334
+
335
+ ```
336
+ pp cmd alias st promote status `pp st` is `pp promote status`
337
+ pp cmd alias every alias, and what it runs
338
+ pp cmd alias st --rm take the name back
339
+ ```
340
+
341
+ The names live in `[alias]` in `~/.config/pp/config.toml`, and this is the
342
+ one place pp writes that file. A name that is a command's own is refused,
343
+ so an alias never hides a command, and `pp help st` and `pp st <TAB>` say
344
+ what `pp promote status` does.
345
+
329
346
  pp clones the plugin into `~/.local/share/pp/plugins/` and checks out the
330
347
  commit you said yes to. Only `pp cmd update` moves it, after showing you
331
348
  the diff. A plugin's code runs as you, so read it before you say yes.
332
349
 
333
- A program named `pp-NAME` on your `PATH`, in any language, is a command
334
- too. To add a command to pp itself, see [CONTRIBUTING.md](https://github.com/xflash96/pp/blob/main/CONTRIBUTING.md).
350
+ A program named `pp-NAME` in `~/.config/pp/commands/`, in a plugin or in a
351
+ `PP_PATH` folder, in any language, is a command too. Your `PATH` is not
352
+ read, so a program named `pp-*` that other software installs takes no
353
+ command's name. To add a command to pp itself, see [CONTRIBUTING.md](https://github.com/xflash96/pp/blob/main/CONTRIBUTING.md).
335
354
 
336
355
  ## Status
337
356
 
@@ -39,12 +39,16 @@ pp needs no packages beyond Python itself, and it completes in zsh and bash.
39
39
 
40
40
  Each command is one Python file. pp looks for them when it runs: its own in
41
41
  `pp/commands/`, yours in `~/.config/pp/commands/`, the plugins you added
42
- with `pp cmd add`, the folders listed in `PP_PATH`, and any program named
43
- `pp-NAME` on your `PATH`. The first one found wins, so a plugin cannot hide
44
- a built-in or one of yours. `pp` lists commands without loading them, so
45
- one broken command cannot break the others.
42
+ with `pp cmd add`, and the folders listed in `PP_PATH`. A program named
43
+ `pp-NAME` in one of those folders, pp's own aside, is a command too. The
44
+ first one found wins, so a plugin cannot hide a built-in or one of yours.
45
+ `pp` lists commands without loading them, so one broken command cannot
46
+ break the others.
46
47
  `pp help NAME` prints a command's manual, with examples.
47
48
 
49
+ A command answers to its file's name, to a unique prefix of it, and to any
50
+ name you give it with `pp cmd alias`.
51
+
48
52
  Everything pp keeps is in plain files:
49
53
 
50
54
  ```
@@ -304,12 +308,27 @@ pp cmd rm REPO
304
308
  pp cmd ls every command, and where it comes from
305
309
  ```
306
310
 
311
+ Any command can have a name of your own:
312
+
313
+ ```
314
+ pp cmd alias st promote status `pp st` is `pp promote status`
315
+ pp cmd alias every alias, and what it runs
316
+ pp cmd alias st --rm take the name back
317
+ ```
318
+
319
+ The names live in `[alias]` in `~/.config/pp/config.toml`, and this is the
320
+ one place pp writes that file. A name that is a command's own is refused,
321
+ so an alias never hides a command, and `pp help st` and `pp st <TAB>` say
322
+ what `pp promote status` does.
323
+
307
324
  pp clones the plugin into `~/.local/share/pp/plugins/` and checks out the
308
325
  commit you said yes to. Only `pp cmd update` moves it, after showing you
309
326
  the diff. A plugin's code runs as you, so read it before you say yes.
310
327
 
311
- A program named `pp-NAME` on your `PATH`, in any language, is a command
312
- too. To add a command to pp itself, see [CONTRIBUTING.md](https://github.com/xflash96/pp/blob/main/CONTRIBUTING.md).
328
+ A program named `pp-NAME` in `~/.config/pp/commands/`, in a plugin or in a
329
+ `PP_PATH` folder, in any language, is a command too. Your `PATH` is not
330
+ read, so a program named `pp-*` that other software installs takes no
331
+ command's name. To add a command to pp itself, see [CONTRIBUTING.md](https://github.com/xflash96/pp/blob/main/CONTRIBUTING.md).
313
332
 
314
333
  ## Status
315
334
 
@@ -1,4 +1,4 @@
1
1
  """pp: a CLI kit for user & agents. One file per subcommand, discovered at runtime."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.2"
4
4
  TAGLINE = "a CLI kit for user & agents" # pp -h's header; README and pyproject say it too (tests/test_cli.py)
@@ -6,11 +6,18 @@ A command is found in one of five places, first match wins (so a plugin cannot s
6
6
  user ~/.config/pp/commands/<name>.py this machine's own, never published
7
7
  plugin ~/.local/share/pp/plugins/<plugin>/[commands/]<name>.py added by `pp cmd add`, pinned to a commit
8
8
  path <dir>/<name>.py for dir in $PP_PATH (os.pathsep-separated)
9
- external pp-<name> executable on $PATH any language, git-style
9
+ external pp-<name> executable in the user, plugin or $PP_PATH folder any language, git-style
10
+
11
+ Every Python command is found before any pp-<name> program, and the programs in the folder order above. $PATH
12
+ is not read: other software installs programs named pp-*, and one of those would take a command's name.
10
13
 
11
14
  A Python command is a module whose docstring's first line is its summary and which defines main(argv) -> int.
12
15
  Listing reads modules with `ast` and never imports them, so a command whose file is absent, unparsable or whose
13
16
  imports fail cannot break `pp -h` or any other command. Publishing a subset of pp is deleting files.
17
+
18
+ A word is resolved in one order: a command's exact name, then `[alias]` in config.toml, then a module's own
19
+ `ALIASES`, then a unique prefix. `[alias]` names any command anything on this machine, and `pp cmd alias`
20
+ writes it.
14
21
  """
15
22
  from __future__ import annotations
16
23
 
@@ -109,6 +116,11 @@ def _py_dirs() -> list[tuple[str, str]]:
109
116
  return dirs
110
117
 
111
118
 
119
+ def _prog_dirs() -> list[str]:
120
+ """Where a pp-NAME program is a command: pp's own folders, less the one pp keeps its own modules in."""
121
+ return [d for origin, d in _py_dirs() if origin != "builtin"]
122
+
123
+
112
124
  def discover() -> dict[str, Command]:
113
125
  found: dict[str, Command] = {}
114
126
 
@@ -129,9 +141,9 @@ def discover() -> dict[str, Command]:
129
141
  path = os.path.join(d, fn)
130
142
  summary, aliases, err = _scan(path)
131
143
  add(Command(fn[:-3], origin, path, summary, aliases, err))
132
- for d in os.environ.get("PATH", "").split(os.pathsep):
144
+ for d in _prog_dirs():
133
145
  try:
134
- names = sorted(os.listdir(d or "."))
146
+ names = sorted(os.listdir(d))
135
147
  except OSError:
136
148
  continue
137
149
  for fn in names:
@@ -166,6 +178,85 @@ def resolve(name: str, cmds: dict[str, Command]) -> Command:
166
178
  ui.die(f"no command '{name}'{hint}", 2)
167
179
 
168
180
 
181
+ # ---- [alias] ----------------------------------------------------------------------------------------------
182
+ # `[alias]` in config.toml gives any command any name on this machine. The table is read where a word is not a
183
+ # command's exact name, and for the listings and the completions below, so dispatching a command pp ships reads
184
+ # no config file at all. One word in, one expansion out: an alias's own first word is resolved without the
185
+ # table, so there is no chain to follow, and an alias can hide no command, since the exact name is read first.
186
+
187
+ ALIAS_KEY = re.compile(r"[a-z][a-z0-9_-]*")
188
+
189
+
190
+ def alias_table(cmds: dict[str, Command]) -> tuple[dict[str, tuple[list[str], str | None]], str | None]:
191
+ """`[alias]` as {name: (the words it runs, why it is skipped)}, and why config.toml could not be read.
192
+
193
+ This raises nothing: `pp -h` prints both as a note, and an entry that cannot work is skipped without
194
+ touching the entries beside it. The word that needs an alias is where an unreadable file becomes an error.
195
+ """
196
+ try:
197
+ table = config.load("alias")
198
+ except config.ConfigError as e:
199
+ return {}, str(e)
200
+ return {key: _alias_of(key, value, cmds) for key, value in table.items()}, None
201
+
202
+
203
+ def _alias_of(key: str, value, cmds: dict[str, Command]) -> tuple[list[str], str | None]:
204
+ """One entry of `[alias]`: the words it runs, and why pp skips it."""
205
+ # a campaign line's own splitter: a shell's word rules, no shell, and no expansion of a variable or a `~`
206
+ from pp.lib import jobpolicy # imported here: every command imports this module, and only this needs it
207
+ argv: list[str] = []
208
+ if isinstance(value, str):
209
+ try:
210
+ argv = jobpolicy.words_of(value)[0]
211
+ except ValueError as e:
212
+ return argv, str(e)
213
+ elif isinstance(value, list) and all(isinstance(w, str) for w in value):
214
+ argv = list(value)
215
+ else:
216
+ return argv, "a value is a command line or a list of words"
217
+ if not ALIAS_KEY.fullmatch(key):
218
+ return argv, "a name is a letter, then letters, digits, _ and -"
219
+ if key == "help":
220
+ return argv, "`pp help CMD` is pp's own"
221
+ if key in cmds:
222
+ return argv, f"the name of a {cmds[key].origin} command ({ui.printable(cmds[key].path)})"
223
+ if not argv:
224
+ return argv, "it names no command"
225
+ if not isinstance(find(argv[0], cmds), Command):
226
+ return argv, f"no command '{ui.printable(argv[0])}'"
227
+ return argv, None
228
+
229
+
230
+ def expand(words: list[str], cmds: dict[str, Command], quiet: bool = False) -> list[str]:
231
+ """`words` with a leading `[alias]` name replaced once by what it stands for: the alias's own words, then
232
+ the words typed after it. Anything else comes back as it was.
233
+
234
+ A file that cannot be parsed is this word's error, as any bad config file is, unless `quiet`: a completion
235
+ offers what it can and says nothing.
236
+ """
237
+ if not words or words[0] in cmds:
238
+ return words
239
+ table, err = alias_table(cmds)
240
+ if err:
241
+ if quiet:
242
+ return words
243
+ raise config.ConfigError(err)
244
+ argv, why = table.get(words[0]) or ([], "not an alias")
245
+ return words if why else argv + words[1:]
246
+
247
+
248
+ def alias_names(cmds: dict[str, Command]) -> list[tuple[str, list[str]]]:
249
+ """Each name `[alias]` gives with the words it runs, the skipped ones left out: what a listing offers."""
250
+ return sorted((n, argv) for n, (argv, why) in alias_table(cmds)[0].items() if not why)
251
+
252
+
253
+ def aliases_of(cmd: Command, named: list[tuple[str, list[str]]]) -> list[str]:
254
+ """A command's own ALIASES, less a name `[alias]` gives away: the table is read before a module's ALIASES,
255
+ so that word runs what the table says and the module is not reached by it. A listing offers these."""
256
+ taken = {n for n, _argv in named}
257
+ return [a for a in cmd.aliases if a not in taken]
258
+
259
+
169
260
  def _load(cmd: Command):
170
261
  if cmd.origin == "builtin":
171
262
  return importlib.import_module(f"pp.commands.{cmd.name}")
@@ -213,7 +304,9 @@ def help_text(cmds: dict[str, Command], show_all: bool = False) -> str:
213
304
  ""]
214
305
  heads = {"builtin": "commands:", "user": f"user commands ({os.path.join(paths.config_dir(), 'commands')}):",
215
306
  "plugin": f"plugins ({paths.data_dir('plugins', create=False)}):", "path": "PP_PATH commands:",
216
- "external": "external (pp-* on PATH):"}
307
+ "external": "external (pp-* in the user, plugin and PP_PATH folders):"}
308
+ table, unreadable = alias_table(cmds)
309
+ named = sorted((n, argv) for n, (argv, why) in table.items() if not why)
217
310
  for origin in ORIGINS:
218
311
  group = sorted((c for c in cmds.values() if c.origin == origin and not c.error), key=lambda c: c.name)
219
312
  if not group:
@@ -221,26 +314,37 @@ def help_text(cmds: dict[str, Command], show_all: bool = False) -> str:
221
314
  out.append(heads[origin])
222
315
  rows = []
223
316
  for c in group:
224
- label = c.name + (f" ({', '.join(c.aliases)})" if c.aliases else "")
317
+ own = aliases_of(c, named)
318
+ label = c.name + (f" ({', '.join(own)})" if own else "")
225
319
  extra = f" [{ui.printable(c.path)}]" if show_all else ""
226
320
  if show_all and c.shadows:
227
321
  extra += " shadows: " + ", ".join(f"{s.origin}:{ui.printable(s.path)}" for s in c.shadows)
228
322
  rows.append([ui.printable(label), ui.printable(c.summary) + extra])
229
323
  out.append(ui.table(rows, indent=" "))
230
324
  out.append("")
325
+ if named:
326
+ out.append("aliases (config.toml):")
327
+ out.append(ui.table([[ui.printable(n), ui.printable(" ".join(argv))] for n, argv in named], indent=" "))
328
+ out.append("")
231
329
  hidden = [s for c in cmds.values() for s in c.shadows if s.origin != "external"]
232
330
  if hidden and not show_all: # a file someone wrote that can never run: say so where they look
233
331
  out.append(ui.c("hidden by a same-named command found first (rename to use):", "yellow"))
234
332
  out.append(ui.table([[ui.printable(h.name), f"{h.origin} [{ui.printable(h.path)}]"]
235
333
  for h in sorted(hidden, key=lambda h: h.name)], indent=" "))
236
334
  out.append("")
237
- broken = sorted((c for c in cmds.values() if c.error), key=lambda c: c.name)
335
+ cfg = ui.printable(config.global_path())
336
+ broken = [[ui.printable(c.name), f"{ui.printable(c.error)} [{ui.printable(c.path)}]"]
337
+ for c in sorted((c for c in cmds.values() if c.error), key=lambda c: c.name)]
338
+ broken += [[ui.printable(n), f"{ui.printable(why)} [{cfg}]"]
339
+ for n, (argv, why) in sorted(table.items()) if why]
340
+ if unreadable:
341
+ broken.append(["[alias]", ui.printable(unreadable)])
238
342
  if broken:
239
343
  out.append(ui.c("broken (skipped):", "yellow"))
240
- out.append(ui.table([[ui.printable(c.name), f"{ui.printable(c.error)} [{ui.printable(c.path)}]"]
241
- for c in broken], indent=" "))
344
+ out.append(ui.table(broken, indent=" "))
242
345
  out.append("")
243
346
  out.append("Unique prefixes work (`pp pro` = `pp promote`). `pp cmd new NAME --user` writes one of your own.")
347
+ out.append("`pp cmd alias st promote status` makes `pp st` run `pp promote status` ([alias] in config.toml).")
244
348
  return "\n".join(out)
245
349
 
246
350
 
@@ -282,9 +386,11 @@ def _main(argv: list[str]) -> int:
282
386
  if not argv or set(argv) <= {"-h", "--help", "--all"}:
283
387
  print(help_text(cmds, show_all="--all" in argv))
284
388
  return 0
285
- if argv[0] == "--commands": # for shell completion: name<TAB>summary, aliases included
389
+ if argv[0] == "--commands": # for shell completion: name<TAB>summary, aliases of both kinds included
390
+ named = alias_names(cmds)
286
391
  _for_a_shell([[n, c.summary] for c in sorted(cmds.values(), key=lambda c: c.name) if not c.error
287
- for n in (c.name, *c.aliases)])
392
+ for n in (c.name, *aliases_of(c, named))]
393
+ + [[n, " ".join(words)] for n, words in named])
288
394
  return 0
289
395
  if argv[0] == "--complete": # for shell completion: candidates for the last of the words after `pp`
290
396
  _for_a_shell([[cand] for cand in complete(argv[1:], cmds)])
@@ -299,9 +405,11 @@ def _main(argv: list[str]) -> int:
299
405
  if not rest:
300
406
  print(help_text(cmds, show_all="--all" in argv))
301
407
  return 0
408
+ rest = expand(rest, cmds) # `pp help st` is the help of what `st` runs: `pp promote status --help`
302
409
  return run(resolve(rest[0], cmds), rest[1:] + ["--help"]) # `pp help queue add` = `pp queue add -h`
303
410
  if argv[0].startswith("-"):
304
411
  ui.die(f"unknown option {argv[0]}; `pp -h` for usage", 2)
412
+ argv = expand(argv, cmds)
305
413
  return run(resolve(argv[0], cmds), argv[1:])
306
414
 
307
415
 
@@ -370,11 +478,13 @@ def complete(words: list[str], cmds: dict[str, Command]) -> list[str]:
370
478
  before = before[1:]
371
479
  if not before:
372
480
  names = {n for c in cmds.values() if not c.error for n in (c.name, *c.aliases)}
481
+ names |= {n for n, _ in alias_names(cmds)}
373
482
  if words[:1] != ["help"]:
374
483
  names.add("help")
375
484
  return sorted(n for n in names if n.startswith(cur))
376
485
  if "--" in before: # past `--`: the rest is someone else's command line
377
486
  return []
487
+ before = expand(before, cmds, quiet=True) # an alias completes as the words it stands for do
378
488
  cmd = find(before[0], cmds)
379
489
  if not isinstance(cmd, Command):
380
490
  return []