ppkit 0.2.0__tar.gz → 0.2.1__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.
- {ppkit-0.2.0 → ppkit-0.2.1}/CHANGELOG.md +112 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/CONTRIBUTING.md +7 -3
- {ppkit-0.2.0 → ppkit-0.2.1}/PKG-INFO +26 -7
- {ppkit-0.2.0 → ppkit-0.2.1}/README.md +25 -6
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/__init__.py +1 -1
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/cli.py +120 -10
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/commands/cmd.py +227 -6
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/commands/queue.py +89 -29
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/lib/config.py +10 -2
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/lib/jobcore.py +13 -8
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/lib/jobpolicy.py +65 -7
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/lib/jobq.py +165 -62
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/lib/proc.py +1 -1
- ppkit-0.2.1/pp/lib/session.py +97 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/pyproject.toml +1 -1
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/journey.py +11 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/sandbox.py +5 -1
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/test_cli.py +19 -1
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/test_cmd.py +55 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/test_journey_cli.py +72 -7
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/test_journey_cmd.py +108 -4
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/test_journey_kill.py +4 -1
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/test_journey_queue.py +137 -9
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/test_queue.py +352 -4
- ppkit-0.2.1/tests/test_session.py +65 -0
- ppkit-0.2.0/pp/lib/session.py +0 -31
- ppkit-0.2.0/tests/test_session.py +0 -24
- {ppkit-0.2.0 → ppkit-0.2.1}/.pp.toml +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/LICENSE +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/bin/pp +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/ci/check +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/ci/package +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/__main__.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/commands/__init__.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/commands/batch.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/commands/completion.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/commands/http.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/commands/kill.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/commands/promote.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/lib/__init__.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/lib/jobstore.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/lib/lock.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/lib/paths.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/pp/lib/ui.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/test_batch.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/test_constructs.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/test_http.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/test_journey_batch.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/test_journey_http.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/test_journey_promote.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/test_kill.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/test_lock.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/test_paths.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/test_proc.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/test_promote.py +0 -0
- {ppkit-0.2.0 → ppkit-0.2.1}/tests/test_ui.py +0 -0
|
@@ -1,5 +1,117 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.2.1
|
|
4
|
+
|
|
5
|
+
pp finds the session that runs it with nothing set, so `--mine` works for
|
|
6
|
+
any agent and in a terminal. `pp queue` reads the cards with a command of
|
|
7
|
+
yours, and any command answers to an alias of your own. A `pp-NAME` program
|
|
8
|
+
is a command only in pp's own folders, and no longer on your `PATH`.
|
|
9
|
+
|
|
10
|
+
`pp`: a program named `pp-NAME` on your `PATH` is no longer a command.
|
|
11
|
+
|
|
12
|
+
- pp looks for such a program in `~/.config/pp/commands/`, in each plugin
|
|
13
|
+
and in each `PP_PATH` folder, and reads no `PATH` for one. Other software
|
|
14
|
+
installs programs named `pp-*`, and on `PATH` such a program takes a
|
|
15
|
+
command's name or a unique prefix of it (`pp q`) and shows in `pp -h`. A
|
|
16
|
+
program of yours stays a command by moving it into
|
|
17
|
+
`~/.config/pp/commands/`, or by listing the folder it is in in `PP_PATH`.
|
|
18
|
+
- Every Python command is still found before any such program, and the
|
|
19
|
+
programs in that folder order, so nothing pp found in those folders
|
|
20
|
+
moves.
|
|
21
|
+
|
|
22
|
+
`pp queue`: the card reading and the calling session are named by you, with
|
|
23
|
+
nvidia-smi and the process pp runs under as the defaults.
|
|
24
|
+
|
|
25
|
+
- `[resources] gpu_gb = { probe = "CMD" }` reads the machine's cards with a
|
|
26
|
+
command of yours instead of nvidia-smi. CMD prints one line a card,
|
|
27
|
+
`index,name,total_MiB,used_MiB,util`, which is nvidia-smi's own
|
|
28
|
+
`--format=csv,noheader,nounits` line, and the index it prints is the one pp
|
|
29
|
+
reserves and publishes in `CUDA_VISIBLE_DEVICES`. The reading is cached for
|
|
30
|
+
the pool's `every` and taken under the probe's own claim, as any probe's is.
|
|
31
|
+
A probe in a `[queue.NAME.resources] gpu_gb` is still refused: one machine
|
|
32
|
+
has one set of cards.
|
|
33
|
+
- `pp queue status --json` gains `cards_why`: why `cards` is null, and `""`
|
|
34
|
+
where the cards were read.
|
|
35
|
+
- pp finds the calling session from the process it runs under: the nearest
|
|
36
|
+
process above pp that is neither a shell nor a wrapper, named with its pid,
|
|
37
|
+
its start time and this boot. So `--mine` and a job's `owner` are any
|
|
38
|
+
agent's with nothing set, since every command an agent runs is a child of
|
|
39
|
+
the agent's own process, and a person's terminal is a session the same way.
|
|
40
|
+
`PP_SESSION`, where it is set and not empty, is the name an agent gives its
|
|
41
|
+
own session instead, and wins over `CLAUDE_CODE_SESSION_ID`. A process pp
|
|
42
|
+
started itself, a job or its runner or a hook or a stop command, is no
|
|
43
|
+
session. pid 1 and an init are no session either, so a `wait` left running
|
|
44
|
+
by a shell that has returned, which the kernel reparents to an init, says
|
|
45
|
+
there is none instead of answering for another session's jobs. The session
|
|
46
|
+
is found once in a run, so such a wait reports the outcomes it selected to
|
|
47
|
+
the session that queued them and no later `wait --mine` of that session
|
|
48
|
+
reports them again. pp strips those variables from everything it starts.
|
|
49
|
+
- `pp queue status --mine`, which `pp queue --mine` and `ls --mine` are too,
|
|
50
|
+
is the jobs this session queued, in every state, in the text view and in
|
|
51
|
+
`--json`'s `running`, `queued`, `held` and `recent`. It applies together
|
|
52
|
+
with `--here` and `--project`, and narrows the jobs shown and not what the
|
|
53
|
+
queues, the workers and the pools hold. Where there is no session it exits
|
|
54
|
+
2, as `wait --mine` does.
|
|
55
|
+
|
|
56
|
+
`pp`: a command answers to a name of your own.
|
|
57
|
+
|
|
58
|
+
- `[alias]` in `~/.config/pp/config.toml` names any command anything on this
|
|
59
|
+
machine: `st = "promote status"`, or a list of words. A word is resolved
|
|
60
|
+
as a command's own name, then `[alias]`, then a module's `ALIASES`, then a
|
|
61
|
+
unique prefix, so an alias hides no command and beats a prefix. `pp help
|
|
62
|
+
st` and `pp st <TAB>` follow the expansion.
|
|
63
|
+
- An alias runs no other alias. One whose key is a command's name or `help`,
|
|
64
|
+
or outside `[a-z][a-z0-9_-]*`, or whose first word names no command, is
|
|
65
|
+
skipped and listed under `pp -h`'s "broken (skipped)". The table is read
|
|
66
|
+
only where a word is not a command's exact name, so a config file that
|
|
67
|
+
cannot be parsed leaves every command pp ships working.
|
|
68
|
+
- `pp cmd alias` lists every alias of both kinds, with what it runs and
|
|
69
|
+
where it is declared (`--json` for the same as data). `pp cmd alias NAME
|
|
70
|
+
CMD [ARGS...]` adds one, `pp cmd alias NAME --rm` takes it back, and `-n`
|
|
71
|
+
prints the change and writes nothing. Those flags count wherever they
|
|
72
|
+
stand, so `pp cmd alias st promote status -n` is a dry run. A command line
|
|
73
|
+
of its own `-n`, `--rm` or `--json` goes after a `--`: `pp cmd alias dq --
|
|
74
|
+
queue drop -n`.
|
|
75
|
+
- `pp cmd alias` is the one place pp writes `config.toml`: one line of the
|
|
76
|
+
`[alias]` table under a lock, the file's mode kept, every other byte as it
|
|
77
|
+
was. A change it cannot make as one line names the file to edit by hand.
|
|
78
|
+
- `pp cmd ls` shows each command's aliases of both kinds, and its `--json`
|
|
79
|
+
rows gain `config_aliases` beside `aliases`.
|
|
80
|
+
|
|
81
|
+
`pp queue`: the views, the plans and the help say more of what pp knows
|
|
82
|
+
about your cards, your queues and your projects.
|
|
83
|
+
|
|
84
|
+
- `status --json` publishes the gpu_gb pool of each scope in `pools` and
|
|
85
|
+
`shared`, an entry like every other pool's with one field of its own:
|
|
86
|
+
`per_card`, that scope's budget on each card of the reading and what its
|
|
87
|
+
running jobs reserve on that card. The text view shows those numbers card
|
|
88
|
+
by card. So a job is sized against the smallest budget of every scope its
|
|
89
|
+
queue draws from before it is queued, instead of being refused first.
|
|
90
|
+
- `pp queue` covers every queue a `[queue.NAME]` table names, beside the
|
|
91
|
+
queues that hold records and the ones a worker is up for. Such a queue
|
|
92
|
+
with nothing in it is a section with nothing in it and an entry in
|
|
93
|
+
`--json`, and reading it creates no directory and no file. "no queues yet"
|
|
94
|
+
is for a machine with none of the three.
|
|
95
|
+
- `pp queue add` warns on stderr where the job has no runner, no `.pp.toml`
|
|
96
|
+
is in or above its directory, and a directory directly below is a project
|
|
97
|
+
whose `[queue]` sets one. The warning names that directory and `--cwd`,
|
|
98
|
+
once per directory, and the job is queued as it asks.
|
|
99
|
+
- The command `add` hands an agent names the ids it queued as ranges (`wait
|
|
100
|
+
8-13 20`), which is one word for a campaign of a hundred lines.
|
|
101
|
+
- `clean` and `drop` say in their plan that they remove the records and pp's
|
|
102
|
+
own files for them, and that a log at a path a `log` pattern or `--log`
|
|
103
|
+
named stays.
|
|
104
|
+
- The extra run of the project's stop command once a run is over writes to
|
|
105
|
+
the worker's log instead of the job's log. The stop a kill or a timeout
|
|
106
|
+
asked for still writes in the job's log.
|
|
107
|
+
- `pp help queue` names the shape of each `--json`: one object with its keys
|
|
108
|
+
for `status`, one job and its watchers for `show`, and a list of `{queue,
|
|
109
|
+
pid, how, log, up}` for `worker status`.
|
|
110
|
+
- `pp help queue` says that several selection forms are several words on one
|
|
111
|
+
verb, that a tag or a prefix selects every job of that name the store
|
|
112
|
+
holds, an earlier round's ended ones too, and that a campaign line's
|
|
113
|
+
`--cwd` is that line's own directory and so its project.
|
|
114
|
+
|
|
3
115
|
## v0.2.0
|
|
4
116
|
|
|
5
117
|
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
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ppkit
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
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
|
|
65
|
-
`pp-NAME`
|
|
66
|
-
a built-in or one of yours.
|
|
67
|
-
one broken command cannot
|
|
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`
|
|
334
|
-
|
|
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
|
|
43
|
-
`pp-NAME`
|
|
44
|
-
a built-in or one of yours.
|
|
45
|
-
one broken command cannot
|
|
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`
|
|
312
|
-
|
|
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
|
|
|
@@ -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
|
|
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
|
|
144
|
+
for d in _prog_dirs():
|
|
133
145
|
try:
|
|
134
|
-
names = sorted(os.listdir(d
|
|
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-*
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 []
|