ppkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pp/__init__.py +4 -0
- pp/__main__.py +5 -0
- pp/cli.py +363 -0
- pp/commands/__init__.py +1 -0
- pp/commands/batch.py +848 -0
- pp/commands/cmd.py +545 -0
- pp/commands/completion.py +55 -0
- pp/commands/http.py +1499 -0
- pp/commands/kill.py +293 -0
- pp/commands/promote.py +945 -0
- pp/commands/queue.py +243 -0
- pp/lib/__init__.py +1 -0
- pp/lib/config.py +110 -0
- pp/lib/jobq.py +3106 -0
- pp/lib/lock.py +338 -0
- pp/lib/paths.py +163 -0
- pp/lib/proc.py +706 -0
- pp/lib/session.py +31 -0
- pp/lib/ui.py +189 -0
- ppkit-0.1.0.dist-info/METADATA +330 -0
- ppkit-0.1.0.dist-info/RECORD +24 -0
- ppkit-0.1.0.dist-info/WHEEL +4 -0
- ppkit-0.1.0.dist-info/entry_points.txt +3 -0
- ppkit-0.1.0.dist-info/licenses/LICENSE +202 -0
pp/lib/session.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Who is calling pp: a person's shell, or an agent session.
|
|
2
|
+
|
|
3
|
+
Claude Code puts its session's identity in the environment of every command it runs (CLAUDE_CODE_SESSION_ID,
|
|
4
|
+
CLAUDE_PID, and TMUX_PANE when it runs inside tmux). pp records that identity as a queued job's owner, so
|
|
5
|
+
`pp queue wait --mine` finds the jobs a session queued. It strips it from anything pp starts on its own (workers,
|
|
6
|
+
jobs, hooks): a process that inherits a session's identity looks, to itself and to every tool it runs, as if it
|
|
7
|
+
were inside that session.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
# a session's identity, never configuration: CLAUDE_CODE_USE_BEDROCK and friends are auth settings and stay
|
|
14
|
+
SESSION_VARS = ("CLAUDECODE", "AI_AGENT", "CLAUDE_PID", "CLAUDE_EFFORT", "CLAUDE_CODE_SESSION_ID",
|
|
15
|
+
"CLAUDE_CODE_CHILD_SESSION", "CLAUDE_CODE_SESSION_ATTENDED", "CLAUDE_CODE_MESSAGING_SOCKET",
|
|
16
|
+
"CLAUDE_CODE_MESSAGING_TOKEN", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_EXECPATH", "TMUX_PANE")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def caller(env: dict | None = None) -> dict | None:
|
|
20
|
+
"""The calling agent session, {session, cwd}, or None when a person (or a script) calls."""
|
|
21
|
+
env = os.environ if env is None else env
|
|
22
|
+
sid = env.get("CLAUDE_CODE_SESSION_ID")
|
|
23
|
+
return {"session": sid, "cwd": os.getcwd()} if sid else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def scrubbed_env(env: dict | None = None) -> dict:
|
|
27
|
+
"""A copy of env without any session's identity."""
|
|
28
|
+
env = dict(os.environ if env is None else env)
|
|
29
|
+
for k in SESSION_VARS:
|
|
30
|
+
env.pop(k, None)
|
|
31
|
+
return env
|
pp/lib/ui.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Terminal helpers shared by commands: argument parsers, confirmation, tables, colour, human units."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import re
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import unicodedata
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parser(cmd: str, doc: str | None, **kw) -> argparse.ArgumentParser:
|
|
14
|
+
"""An ArgumentParser for `pp <cmd>`: the module docstring is the help text, kept verbatim."""
|
|
15
|
+
return argparse.ArgumentParser(prog=f"pp {cmd}", description=doc,
|
|
16
|
+
formatter_class=argparse.RawDescriptionHelpFormatter, **kw)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_intermixed(p: argparse.ArgumentParser, argv: list[str]) -> argparse.Namespace:
|
|
20
|
+
"""p.parse_intermixed_args(argv), the same on every Python pp supports: options may sit between positionals
|
|
21
|
+
(`pp batch rename ' ' _ -r .`, which plain parse_args gets wrong on 3.11 and early 3.12), and after `--` every
|
|
22
|
+
argument is a positional. Those versions' intermixed parse also lost `--` and read a later '-x' as an
|
|
23
|
+
option, so such arguments pass through argparse as placeholders that cannot look like options."""
|
|
24
|
+
if "--" not in argv:
|
|
25
|
+
return p.parse_intermixed_args(argv)
|
|
26
|
+
i = argv.index("--")
|
|
27
|
+
held = {f"\0{n}": a for n, a in enumerate(argv[i + 1:]) if a.startswith("-")}
|
|
28
|
+
tail = [f"\0{n}" if a.startswith("-") else a for n, a in enumerate(argv[i + 1:])]
|
|
29
|
+
ns = p.parse_intermixed_args(argv[:i + 1] + tail)
|
|
30
|
+
for k, v in vars(ns).items():
|
|
31
|
+
if isinstance(v, str):
|
|
32
|
+
setattr(ns, k, held.get(v, v))
|
|
33
|
+
elif isinstance(v, list):
|
|
34
|
+
setattr(ns, k, [held.get(x, x) if isinstance(x, str) else x for x in v])
|
|
35
|
+
return ns
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def die(msg: str, code: int = 1):
|
|
39
|
+
"""Print `pp: msg` to stderr and exit with code (1 failure, 2 usage/refusal)."""
|
|
40
|
+
print(f"pp: {msg}", file=sys.stderr)
|
|
41
|
+
raise SystemExit(code)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def warn(msg: str):
|
|
45
|
+
print(f"pp: {msg}", file=sys.stderr)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def confirm(prompt: str, yes: bool = False) -> bool:
|
|
49
|
+
"""True to proceed. -y always proceeds; without a terminal to ask on we refuse rather than guess."""
|
|
50
|
+
sys.stdout.flush() # the plan printed so far comes before the question or the refusal, even into a pipe
|
|
51
|
+
if yes:
|
|
52
|
+
return True
|
|
53
|
+
if not sys.stdin.isatty():
|
|
54
|
+
print(f"{prompt} There is no terminal to ask at; pass -y to proceed.", file=sys.stderr)
|
|
55
|
+
return False
|
|
56
|
+
try:
|
|
57
|
+
return input(f"{prompt} [y/N] ").strip().lower() in ("y", "yes")
|
|
58
|
+
except EOFError:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---- colour ---------------------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
_STYLES = {"bold": "1", "dim": "2", "red": "31", "green": "32", "yellow": "33", "blue": "34", "magenta": "35", "cyan": "36"}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def color_on(stream=None) -> bool:
|
|
68
|
+
stream = stream or sys.stdout
|
|
69
|
+
return stream.isatty() and not os.environ.get("NO_COLOR") and os.environ.get("TERM") != "dumb"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def c(text: str, *styles: str, stream=None) -> str:
|
|
73
|
+
if not styles or not color_on(stream):
|
|
74
|
+
return text
|
|
75
|
+
return "\033[" + ";".join(_STYLES[s] for s in styles) + "m" + text + "\033[0m"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---- output ---------------------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
# every control character but tab, the C1 range (\x80-\x9f) among them: a terminal reads \x9b as a
|
|
81
|
+
# control sequence introducer, the way it reads \x1b[
|
|
82
|
+
_CTRL = re.compile(r"[\x00-\x08\x0a-\x1f\x7f-\x9f]")
|
|
83
|
+
_CTRL_TAB = re.compile(r"[\x00-\x1f\x7f-\x9f]")
|
|
84
|
+
_SGR = re.compile(r"\x1b\[[0-9;]*m") # what c() wraps a cell in: bytes, no columns
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def printable(text: str, tabs: bool = True) -> str:
|
|
88
|
+
"""Text from elsewhere (another program's argv, a plugin's file, git's output, a requested URL) made safe
|
|
89
|
+
to print: control characters shown as \\n, \\x1b and so on, so an escape sequence cannot redraw the
|
|
90
|
+
terminal around what the user is reading. A tab stays a tab unless tabs is False (in a table, where it
|
|
91
|
+
would break the columns). The one escaper in pp: CONTRIBUTING.md, "Adding a command"."""
|
|
92
|
+
return (_CTRL if tabs else _CTRL_TAB).sub(lambda m: repr(m.group())[1:-1], text)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _char_width(ch: str) -> int:
|
|
96
|
+
"""How many columns one character takes, which width() and cut() both count in, so a cut and the measure
|
|
97
|
+
that decided on it can never disagree.
|
|
98
|
+
|
|
99
|
+
A combining mark is drawn on the character before it, so it takes no column of its own: an accent in an
|
|
100
|
+
NFD name (a copy from a Mac, an unzipped archive, some input methods) is such a mark, and so are the
|
|
101
|
+
other zero-width characters, a joiner and a variation selector among them. A character East Asian width
|
|
102
|
+
calls wide or fullwidth (a CJK name, a fullwidth form) takes two columns. Anything else takes one."""
|
|
103
|
+
if unicodedata.category(ch) in ("Mn", "Me", "Cf"):
|
|
104
|
+
return 0
|
|
105
|
+
return 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def width(text: str) -> int:
|
|
109
|
+
"""How many columns text takes on a terminal, which is what anything lining text up has to count.
|
|
110
|
+
|
|
111
|
+
A colour from c() is escape bytes the terminal prints nothing for, so they count for nothing; every other
|
|
112
|
+
character counts what _char_width says. Counting characters instead pads a coloured cell nine columns too
|
|
113
|
+
far, a CJK name half as far as it needs, and an NFD name one column per accent too far."""
|
|
114
|
+
return sum(_char_width(ch) for ch in _SGR.sub("", text))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def pad(text: str, to: int) -> str:
|
|
118
|
+
"""text with spaces after it up to `to` printed columns, as str.ljust does with characters."""
|
|
119
|
+
return text + " " * max(to - width(text), 0)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cut(text: str, to: int) -> str:
|
|
123
|
+
"""text, no wider than `to` printed columns, with an ellipsis where it was cut. Cut by columns, so a wide
|
|
124
|
+
character is kept whole or left out, never split across the edge, and a combining mark stays with the
|
|
125
|
+
character it is drawn on. For text without colours: a colour cut in half would run on past the cell."""
|
|
126
|
+
if width(text) <= to:
|
|
127
|
+
return text
|
|
128
|
+
out, used = [], 0
|
|
129
|
+
for ch in text:
|
|
130
|
+
w = _char_width(ch)
|
|
131
|
+
if used + w > to - 1:
|
|
132
|
+
break
|
|
133
|
+
out.append(ch)
|
|
134
|
+
used += w
|
|
135
|
+
return "".join(out) + "…"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def table(rows: list[list], headers: list[str] | None = None, indent: str = "") -> str:
|
|
139
|
+
"""Left-aligned columns; the last column is never padded. Cells are str()'d, with a newline or carriage return
|
|
140
|
+
shown as \\n or \\r, so no cell starts a row of its own. Cells may carry c()'s colours; text from elsewhere
|
|
141
|
+
goes through printable() first.
|
|
142
|
+
|
|
143
|
+
Columns are counted and padded in printed columns (width()), so a coloured cell and a CJK name line up
|
|
144
|
+
like any other and no call site has to know what its cells hold."""
|
|
145
|
+
rows = [[("" if v is None else str(v)).replace("\n", "\\n").replace("\r", "\\r") for v in r] for r in rows]
|
|
146
|
+
allr = ([headers] if headers else []) + rows
|
|
147
|
+
if not allr:
|
|
148
|
+
return ""
|
|
149
|
+
n = max(len(r) for r in allr)
|
|
150
|
+
widths = [max((width(r[i]) for r in allr if i < len(r)), default=0) for i in range(n)]
|
|
151
|
+
out = []
|
|
152
|
+
for k, r in enumerate(allr):
|
|
153
|
+
cells = [pad(cell, widths[i]) if i < len(r) - 1 else cell for i, cell in enumerate(r)]
|
|
154
|
+
line = indent + " ".join(cells)
|
|
155
|
+
out.append(c(line, "bold") if headers and k == 0 else line)
|
|
156
|
+
return "\n".join(out)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def print_json(obj) -> None:
|
|
160
|
+
json.dump(obj, sys.stdout, indent=2, default=str)
|
|
161
|
+
sys.stdout.write("\n")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def human_size(n: float) -> str:
|
|
165
|
+
for unit in ("B", "K", "M", "G", "T"):
|
|
166
|
+
if abs(n) < 1024 or unit == "T":
|
|
167
|
+
return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
|
|
168
|
+
n /= 1024
|
|
169
|
+
return f"{n:.1f}T"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def human_duration(s: float | None) -> str:
|
|
173
|
+
if s is None:
|
|
174
|
+
return "-"
|
|
175
|
+
s = int(s)
|
|
176
|
+
if s < 60:
|
|
177
|
+
return f"{s}s"
|
|
178
|
+
if s < 3600:
|
|
179
|
+
return f"{s // 60}m{s % 60:02d}s"
|
|
180
|
+
if s < 86400:
|
|
181
|
+
return f"{s // 3600}h{(s % 3600) // 60:02d}m"
|
|
182
|
+
return f"{s // 86400}d{(s % 86400) // 3600:02d}h"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def human_age(ts: float | None, now: float | None = None) -> str:
|
|
186
|
+
"""'3m ago' style age of a unix timestamp."""
|
|
187
|
+
if not ts:
|
|
188
|
+
return "-"
|
|
189
|
+
return human_duration((now or time.time()) - ts) + " ago"
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ppkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A CLI kit for user & agents
|
|
5
|
+
Keywords: cli,job queue,gpu,agents,toolkit
|
|
6
|
+
Author: The pp Authors
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: System :: Shells
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Project-URL: Changelog, https://github.com/xflash96/pp/blob/main/CHANGELOG.md
|
|
19
|
+
Project-URL: Homepage, https://github.com/xflash96/pp
|
|
20
|
+
Project-URL: Issues, https://github.com/xflash96/pp/issues
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# pp
|
|
24
|
+
|
|
25
|
+
[](https://github.com/xflash96/pp/actions/workflows/ci.yml)
|
|
26
|
+
[](https://pypi.org/project/ppkit/)
|
|
27
|
+
[](https://github.com/xflash96/pp/blob/main/LICENSE)
|
|
28
|
+
|
|
29
|
+
A CLI kit for user & agents.
|
|
30
|
+
|
|
31
|
+
pp gathers the shell chores that come up when you work next to coding
|
|
32
|
+
agents such as Claude Code on one Linux machine. Long jobs wait their turn
|
|
33
|
+
for the GPU. An agent finds out how its job ended without anyone watching a
|
|
34
|
+
terminal. The copy of a tool you use every day is one that passed its tests,
|
|
35
|
+
not the one being edited.
|
|
36
|
+
|
|
37
|
+
Every command shows what it will do before it does it, and works the same
|
|
38
|
+
when a person or an agent runs it. [Try it](#try-it) queues a first job.
|
|
39
|
+
|
|
40
|
+
## Features
|
|
41
|
+
|
|
42
|
+
- [`pp queue`](#queues) runs commands later, in named queues. A job starts
|
|
43
|
+
when the GPU memory, CPUs, RAM or counters it asks for are free, and it
|
|
44
|
+
can have a timeout, retries and a hook for when it ends.
|
|
45
|
+
- [`pp queue wait`](#working-with-agents) blocks until jobs end, then says
|
|
46
|
+
how each one ended. An agent runs it in the background instead of polling.
|
|
47
|
+
- [`pp promote`](#releases) releases a project from a git commit, tests the
|
|
48
|
+
copy, and switches to it only if the tests pass.
|
|
49
|
+
- [`pp kill`](#everyday-tools) stops processes by pid, port or pattern, and
|
|
50
|
+
never the shell that asked.
|
|
51
|
+
- [`pp batch`](#everyday-tools) renames files, or runs a command on each one,
|
|
52
|
+
with a preview and undo.
|
|
53
|
+
- [`pp http`](#everyday-tools) shows project folders in a browser, with
|
|
54
|
+
markdown and code rendered.
|
|
55
|
+
- [`pp cmd`](#your-own-commands) writes your own commands, and adds other
|
|
56
|
+
people's from git.
|
|
57
|
+
|
|
58
|
+
pp needs no packages beyond Python itself, and it completes in zsh and bash.
|
|
59
|
+
|
|
60
|
+
## How it works
|
|
61
|
+
|
|
62
|
+
Each command is one Python file. pp looks for them when it runs: its own in
|
|
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.
|
|
68
|
+
`pp help NAME` prints a command's manual, with examples.
|
|
69
|
+
|
|
70
|
+
Everything pp keeps is in plain files:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
~/.config/pp/config.toml settings, a table per command
|
|
74
|
+
~/.config/pp/commands/ your own commands
|
|
75
|
+
.pp.toml a project's settings, at its root
|
|
76
|
+
~/.local/state/pp/ queues, job logs, the rename journal
|
|
77
|
+
~/.local/share/pp/releases/ what pp promote released
|
|
78
|
+
~/.local/share/pp/plugins/ plugins, one git clone each
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A queue has a worker, a background process that `pp queue add` starts when
|
|
82
|
+
it is not running. It runs each job in its own process group, in the
|
|
83
|
+
directory the job was added from, with the `PATH` and virtualenv of the
|
|
84
|
+
shell that added it.
|
|
85
|
+
|
|
86
|
+
## Install
|
|
87
|
+
|
|
88
|
+
pp runs on Linux, with Python 3.11 or later. Install it as a tool, in an
|
|
89
|
+
environment of its own:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
uv tool install ppkit # or: pipx install ppkit
|
|
93
|
+
pp --version
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
For completion, add `source <(pp completion zsh)` to `~/.zshrc` after
|
|
97
|
+
`compinit`, or `source <(pp completion bash)` to `~/.bashrc`. To change pp
|
|
98
|
+
itself, run it from a clone instead ([CONTRIBUTING.md](https://github.com/xflash96/pp/blob/main/CONTRIBUTING.md)).
|
|
99
|
+
|
|
100
|
+
## Try it
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
pp queue add -- sh -c 'echo working; sleep 3; echo hello' # job 1, in the default queue; its worker starts
|
|
104
|
+
pp queue # job 1 running, with its last line of output: working
|
|
105
|
+
pp queue wait 1 # waits, then: done 1 rc=0 3s
|
|
106
|
+
pp queue log 1 # working, then hello
|
|
107
|
+
pp queue add --retries 1 --retry-delay 1s -- sh -c 'echo trying; exit 3'
|
|
108
|
+
pp queue wait 2 # FAILED 2 rc=3, after two tries, with the end of its log
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`wait` exits with 0 when every job succeeded and 1 when one failed, so it
|
|
112
|
+
fits in a script: `pp queue wait 2 && ./next-step.sh`.
|
|
113
|
+
|
|
114
|
+
A job that needs a GPU says how much memory it takes:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
pp queue add --gpu-gb 20 -- python train.py
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
It starts when one card has 20 GB free, counting what nvidia-smi reports
|
|
121
|
+
and what running jobs have reserved. The job sees only that card, through
|
|
122
|
+
`CUDA_VISIBLE_DEVICES`.
|
|
123
|
+
|
|
124
|
+
## Queues
|
|
125
|
+
|
|
126
|
+
Each queue has its own worker, limits and order. Job ids are unique across
|
|
127
|
+
queues. A job with a lower `--prio` runs first, and the default is 10. A big
|
|
128
|
+
job at the head of a queue is not overtaken by smaller ones.
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
pp queue add -q dl --need net=1 -- ./fetch.sh the dl queue, taking one of a counter named net
|
|
132
|
+
pp queue add --timeout 2h -- python x.py stopped after two hours
|
|
133
|
+
pp queue add --retries 2 --retry-if 'Connection reset' -- ./sync.sh
|
|
134
|
+
pp queue add --on-fail 'notify-send "job $PP_QUEUE_JOB failed"' -- ./nightly.sh
|
|
135
|
+
pp queue add --tag sweep -- python x.py --lr 0.1 tagged, to act on the group later
|
|
136
|
+
pp queue wait sweep every job tagged sweep
|
|
137
|
+
pp queue watch 12 a line for each start, retry and outcome
|
|
138
|
+
pp queue drop 20-29 a range: it shows the jobs and asks first
|
|
139
|
+
pp queue kill 12 waits while the worker stops it, then:
|
|
140
|
+
killed 12 (stopped on SIGTERM)
|
|
141
|
+
pp queue retry 11 pp queue hold 15 pp queue release 15
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Limits go in `~/.config/pp/config.toml`. Every queue draws on the shared
|
|
145
|
+
GPU memory, CPUs and RAM; a queue's own pool caps what it may take of them.
|
|
146
|
+
A counter is a number, or a command that prints how much is free:
|
|
147
|
+
|
|
148
|
+
```toml
|
|
149
|
+
[resources] # shared by every queue
|
|
150
|
+
license = 4 # --need license=1 takes one
|
|
151
|
+
nodes = { probe = "sinfo -h -t idle -o %D", every = "1m" }
|
|
152
|
+
|
|
153
|
+
[queue.default]
|
|
154
|
+
retries = 1
|
|
155
|
+
retry_if = "Connection reset|503"
|
|
156
|
+
|
|
157
|
+
[queue.dl.resources] # only the dl queue has these
|
|
158
|
+
net = 2
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
A project's `.pp.toml` can wrap every job queued from it, for example in a
|
|
162
|
+
container, and add to the jobs' environment:
|
|
163
|
+
|
|
164
|
+
```toml
|
|
165
|
+
[queue]
|
|
166
|
+
runner = "docker/run.sh" # the job runs as docker/run.sh CMD...
|
|
167
|
+
env = { HF_HUB_OFFLINE = "1" }
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`pp queue add --no-runner` skips the runner for one job. The runner gets the
|
|
171
|
+
job's environment, including `CUDA_VISIBLE_DEVICES`, and a container runner
|
|
172
|
+
has to pass it in. Give a job a token or a secret with `--env` or the
|
|
173
|
+
project's `env`: a variable exported in your shell reaches it only through
|
|
174
|
+
the worker. `pp help queue` has the examples, and `pp help queue add` every
|
|
175
|
+
option of `add`.
|
|
176
|
+
|
|
177
|
+
## Working with agents
|
|
178
|
+
|
|
179
|
+
An agent that starts a long job should not sit in a loop checking on it.
|
|
180
|
+
With pp it runs `pp queue wait ID` in the background. Claude Code tells the
|
|
181
|
+
agent when a background command exits, and the output says how the job
|
|
182
|
+
ended, with the end of the log if it failed. `pp queue watch ID` prints a
|
|
183
|
+
line per event instead, for a monitor. `pp queue add` prints this hint when
|
|
184
|
+
an agent runs it.
|
|
185
|
+
|
|
186
|
+
pp notes which session queued each job. `pp queue wait --mine` waits for
|
|
187
|
+
the jobs this session queued, and also reports the ones that ended before it
|
|
188
|
+
started waiting, so an outcome is not missed:
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
pp queue add -- ./train.sh # prints the job's id, and the hint to wait in the background
|
|
192
|
+
pp queue wait --mine # every job of this session: how it ended, with the log's end on failure
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Nothing is typed into the agent's terminal. pp leaves the session's identity
|
|
196
|
+
out of the jobs it runs, so a job does not think it is inside that session.
|
|
197
|
+
|
|
198
|
+
The commands follow the same rules, so an agent can use them without a
|
|
199
|
+
terminal. A command that changes or deletes things shows what it will do
|
|
200
|
+
and asks, unless you named each thing exactly; `-n` only shows, `-y` goes
|
|
201
|
+
ahead, and without a terminal it refuses instead of asking. Lists and
|
|
202
|
+
status reports have `--json`. The exit status is 0 when it worked, 1 when
|
|
203
|
+
it failed and 2 when it refused; [CONTRIBUTING.md](https://github.com/xflash96/pp/blob/main/CONTRIBUTING.md#exit-status) has
|
|
204
|
+
all of them.
|
|
205
|
+
|
|
206
|
+
## Releases
|
|
207
|
+
|
|
208
|
+
`pp promote` keeps the copy of a tool or service you use apart from the
|
|
209
|
+
tree you edit. It copies a git commit into
|
|
210
|
+
`~/.local/share/pp/releases/NAME/STAMP/`, runs the project's check inside
|
|
211
|
+
that copy, and points the `current` link at it only if the check passes.
|
|
212
|
+
The links you register go through `current`, so they never change.
|
|
213
|
+
|
|
214
|
+
```toml
|
|
215
|
+
# .pp.toml, at the project root
|
|
216
|
+
[promote]
|
|
217
|
+
check = "./test.sh" # must pass, or nothing changes
|
|
218
|
+
links = { "~/.local/bin/notes" = "bin/notes" }
|
|
219
|
+
after = "systemctl --user restart notes" # makes the running service pick up the release
|
|
220
|
+
verify = "curl -fsS http://127.0.0.1:8100/ >/dev/null" # if this fails, pp goes back
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
pp promote -n the plan and every problem, without changing anything
|
|
225
|
+
pp promote release HEAD
|
|
226
|
+
pp promote status each project, its release, and how far its source is ahead
|
|
227
|
+
pp promote rollback notes back to the release before
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`after` runs again on a rollback, so it has to work for any release. A
|
|
231
|
+
database migration that cannot be undone does not belong in it. pp keeps the
|
|
232
|
+
last five releases, and always keeps the current one and the one before.
|
|
233
|
+
|
|
234
|
+
A systemd service runs the release by pointing at `current`:
|
|
235
|
+
|
|
236
|
+
```
|
|
237
|
+
ExecStart=%h/.local/share/pp/releases/notes/current/bin/serve --port 8100
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Everyday tools
|
|
241
|
+
|
|
242
|
+
```
|
|
243
|
+
pp kill :8080 whatever listens on port 8080
|
|
244
|
+
pp kill 'vite --port' processes whose command line matches; it shows them and asks
|
|
245
|
+
pp kill -n :8080 only show what it would stop
|
|
246
|
+
pp kill --ports every listening port and what owns it
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`pkill -f PATTERN` can kill the shell that runs it, because the pattern is
|
|
250
|
+
in that shell's command line. `pp kill` leaves out itself and the processes
|
|
251
|
+
that started it.
|
|
252
|
+
|
|
253
|
+
```
|
|
254
|
+
pp batch rename ' ' '_' '*' spaces to underscores, after a preview
|
|
255
|
+
pp batch rename -t '{parent}_{i:03}{ext}' 'photos/**/*.jpg' trip_001.jpg, trip_002.jpg, ... in each folder
|
|
256
|
+
pp batch run 'ffmpeg -i {} {out}' '*.wav' --out 'mp3/{stem}.mp3' -j 4
|
|
257
|
+
pp batch undo put the last rename back
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
```
|
|
261
|
+
pp http ~/notes . a tab for each folder, at http://127.0.0.1:8000
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
`pp http` listens on 127.0.0.1 unless you pass `--bind`, and does not serve
|
|
265
|
+
files such as `.env` or private keys.
|
|
266
|
+
|
|
267
|
+
## Your own commands
|
|
268
|
+
|
|
269
|
+
```
|
|
270
|
+
pp cmd new hello --user -s "Say hello" writes ~/.config/pp/commands/hello.py, a working start
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
A command is a Python file with a docstring and a `main` function. The
|
|
274
|
+
first line of the docstring is what `pp` lists, and the rest is its help.
|
|
275
|
+
Edit it into what you need. After this edit, `pp hello world` prints
|
|
276
|
+
`hello, world`:
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
"""Say hello
|
|
280
|
+
|
|
281
|
+
pp hello NAME say hello to NAME
|
|
282
|
+
"""
|
|
283
|
+
from pp.lib import ui
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def main(argv: list[str]) -> int:
|
|
287
|
+
ap = ui.parser("hello", __doc__)
|
|
288
|
+
ap.add_argument("name")
|
|
289
|
+
a = ap.parse_args(argv)
|
|
290
|
+
print(f"hello, {a.name}")
|
|
291
|
+
return 0
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Commands that someone else wrote come as a plugin: a git repository with
|
|
295
|
+
command files in its `commands/` folder, or at its top.
|
|
296
|
+
|
|
297
|
+
```
|
|
298
|
+
pp cmd add github.com/OWNER/REPO shows the commands it adds, then asks
|
|
299
|
+
pp cmd add github.com/OWNER/REPO -n --show only shows, with their code
|
|
300
|
+
pp cmd update fetches, shows the diff, then asks
|
|
301
|
+
pp cmd rm REPO
|
|
302
|
+
pp cmd ls every command, and where it comes from
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
pp clones the plugin into `~/.local/share/pp/plugins/` and checks out the
|
|
306
|
+
commit you said yes to. Only `pp cmd update` moves it, after showing you
|
|
307
|
+
the diff. A plugin's code runs as you, so read it before you say yes.
|
|
308
|
+
|
|
309
|
+
A program named `pp-NAME` on your `PATH`, in any language, is a command
|
|
310
|
+
too. To add a command to pp itself, see [CONTRIBUTING.md](https://github.com/xflash96/pp/blob/main/CONTRIBUTING.md).
|
|
311
|
+
|
|
312
|
+
## Status
|
|
313
|
+
|
|
314
|
+
pp is new, and its version is 0.x. A command or a config key may still
|
|
315
|
+
change, and so may a field of `--json` output; the
|
|
316
|
+
[changelog](https://github.com/xflash96/pp/blob/main/CHANGELOG.md) says when one does. CI runs the tests on
|
|
317
|
+
Python 3.11 and 3.14, on Ubuntu 24.04.
|
|
318
|
+
|
|
319
|
+
## Contributing
|
|
320
|
+
|
|
321
|
+
[CONTRIBUTING.md](https://github.com/xflash96/pp/blob/main/CONTRIBUTING.md) says how to test and send a change, and
|
|
322
|
+
what a command needs to fit in. Bugs go to the
|
|
323
|
+
[issue tracker](https://github.com/xflash96/pp/issues).
|
|
324
|
+
|
|
325
|
+
Developed with agent assistance.
|
|
326
|
+
|
|
327
|
+
## License
|
|
328
|
+
|
|
329
|
+
Copyright 2026 The pp Authors. Licensed under the Apache License 2.0; see
|
|
330
|
+
[LICENSE](https://github.com/xflash96/pp/blob/main/LICENSE).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
pp/__init__.py,sha256=XX4APe7_X_VM5uMHXoo1OT70895_u1NJwH8vn6xdcDA,225
|
|
2
|
+
pp/__main__.py,sha256=L-ldKT90Z-_Ex094d3i--sLiTs8-bLTUx2_Z0pT3acA,54
|
|
3
|
+
pp/cli.py,sha256=IqLHaoxu0e1jpuJ6KXKghRCoatCRss6pnJPnRGcz_Ho,15504
|
|
4
|
+
pp/commands/__init__.py,sha256=TvcAOaDdkKAefBhdC2Twgb5Uvfo578lMzMRBQHxxqq8,113
|
|
5
|
+
pp/commands/batch.py,sha256=85Bg3TwkR0jH01pUWTSmn15ZaYHQATYUu9m0ViyPcIY,40754
|
|
6
|
+
pp/commands/cmd.py,sha256=M8wevaPnRcO1SpBmRZ98QX2WamJ_B6Aokq8Uc86PjCo,25779
|
|
7
|
+
pp/commands/completion.py,sha256=RkOUF2v4rWrzsr7TFVNHW16jRbgNEgEvWswpL1p0ocg,1637
|
|
8
|
+
pp/commands/http.py,sha256=qrjfBIdGoiKuThoDE82-TnaGb36e4M3faW03v3WCYw0,77377
|
|
9
|
+
pp/commands/kill.py,sha256=5yRqo6UGvxQ2yGgeNChXz45x3DlRzWCY7YYNng11hSQ,14519
|
|
10
|
+
pp/commands/promote.py,sha256=UGwcV0xEaiXUCg1-V6a52QXKUg0QZLSIRw0wk-TGI7U,46507
|
|
11
|
+
pp/commands/queue.py,sha256=mYtPV6xw4_fuRlCBCXTZsayBkSoyysCW1xKYFTVpcpA,15999
|
|
12
|
+
pp/lib/__init__.py,sha256=MMRvvcZ3s7MSFceL_Wg8K6LirBV4HZBNLQks6khIYEQ,88
|
|
13
|
+
pp/lib/config.py,sha256=PBS0rs-1hzt8gS3RE5RF62XoOzdh9-yXxGwKijbHceU,4468
|
|
14
|
+
pp/lib/jobq.py,sha256=S4CNiryGlT5dCP8ckrlcQTU_2pk7jknx_au46eUlAho,162475
|
|
15
|
+
pp/lib/lock.py,sha256=ZnMlDmW437V5CXKHfaZD2kXoUXgNXqa74Y2oICSDfL4,17276
|
|
16
|
+
pp/lib/paths.py,sha256=kPcwlC1lCcFAlIKnw9LScjsEhKN8Hik9BOABjBid7ak,6927
|
|
17
|
+
pp/lib/proc.py,sha256=ss675Vl2EXoNld-mlpLdZM2-FWvnJSE9C-cQCINe3zs,31770
|
|
18
|
+
pp/lib/session.py,sha256=YY8TlbszqdDksb9wKzvuhNtgnFrgGF7d1CD9cTEpEh4,1537
|
|
19
|
+
pp/lib/ui.py,sha256=hMHmFKP_ty53WG81FcFrdyOPbceANAIUkpKKazIWfeE,8154
|
|
20
|
+
ppkit-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
21
|
+
ppkit-0.1.0.dist-info/WHEEL,sha256=RRVLqVugUmFOqBedBFAmA4bsgFcROUBiSUKlERi0Hcg,79
|
|
22
|
+
ppkit-0.1.0.dist-info/entry_points.txt,sha256=kCQ8mdFRBFFi3n1i8zzGzItQFX2gtRNFL8M_UqS04Ok,36
|
|
23
|
+
ppkit-0.1.0.dist-info/METADATA,sha256=Wu0kUzfhzPMudCm-lJRTQWq1IEMKdTgEfUj1FmcNnX4,13634
|
|
24
|
+
ppkit-0.1.0.dist-info/RECORD,,
|