ppkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pp/commands/queue.py ADDED
@@ -0,0 +1,243 @@
1
+ """Run commands later, under resource limits, in named queues
2
+
3
+ Each named queue has its own limits, its own run order and its own worker; job ids are machine-wide. A job runs
4
+ when its declared needs fit the resource pools. A pool is slots, GPU memory (reserved per card AND read live
5
+ from nvidia-smi), CPUs, RAM, any counter you name, or anything a command can report as free (a cluster, an API
6
+ quota). A job is retried, timed out and reported as you ask.
7
+
8
+ pp queue add -- make docs queue a command in the `default` queue, run in this directory
9
+ pp queue add -q dl --need net=1 -- ./fetch.sh another queue: its own limits, order and worker
10
+ pp queue add --timeout 2h --retries 2 --retry-if 'Connection reset|CUDA out of memory' -- python x.py
11
+ pp queue add --on-fail 'notify-send "job $PP_QUEUE_JOB failed"' -- ./nightly.sh
12
+ pp queue add --from cmds.txt --prio 5 one job per line (# comments and blank lines skipped)
13
+ pp queue add --gpu-gb 20 -- python x.py in a project with a runner (below), run through it
14
+ pp queue add --no-runner -- make docs ... or not
15
+ pp queue add --tag sweep -- python x.py --lr 0.1 a tag names a job, or a group of jobs that share it
16
+ pp queue wait sweep every job tagged sweep (kill, drop, show and log take tags too)
17
+ pp queue status of every queue: running (last log line), queued, recent
18
+ pp queue -q dl one queue
19
+ pp queue show 12 pp queue log 12 -f every field + log tail / follow the log (across retries;
20
+ exits like wait)
21
+ pp queue note 12 "OOM at bs=64; re-added as 15" leave a note on a job (shown in status and show)
22
+ pp queue wait 12-15 block until they end, then print the outcomes (a failure
23
+ with its log tail); exit 0 ok, 1 any failed or dropped,
24
+ 3 timeout
25
+ pp queue wait 12 --timeout 30m ... giving up after 30 minutes (exit 3)
26
+ pp queue watch 12 a line per event as it happens: start, retry, outcome
27
+ pp queue wait --idle ... until nothing is queued or running
28
+ pp queue wait --mine (an agent) every job this session queued that is live
29
+ or whose end no wait/watch in this session reported yet
30
+ pp queue kill 12 waits while the worker stops it: killed 12 (stopped on SIGTERM)
31
+ pp queue drop 13 pp queue retry 11 pp queue reprio 14 0
32
+ pp queue drop 20-29 -n a range or a shared tag: shows what it would do, then asks
33
+ pp queue hold 15 pp queue release 15 pp queue clean --older 7d
34
+ pp queue worker status | start -q NAME | stop -q NAME `add` starts a queue's worker; stop leaves jobs running
35
+
36
+ Agents: run `pp queue wait ID` in the background (or `pp queue watch ID` under a monitor) and you are told
37
+ how the job ended, without polling and without anything typed into your terminal; `add` prints this hint to
38
+ an agent.
39
+
40
+ IDs are numbers, ranges (12-15) or a tag (add --tag NAME; without one, the tag is the command, cut to 40
41
+ characters). -q narrows a selection to one queue. A job named by its id must suit the verb, or it is an error; a
42
+ range, or a tag several jobs share, leaves the jobs it does not suit as they are, and kill and drop show such a
43
+ selection and ask first (-y: do not ask, -n: only show). Lower --prio runs first (default 10), ties by id. Each
44
+ queue is strict head-of-line unless its worker runs with backfill, so a big job is never starved by small
45
+ ones. PATH, VIRTUAL_ENV, CONDA_PREFIX and CONDA_DEFAULT_ENV are captured at add time, so `python` means what
46
+ it meant in your shell.
47
+
48
+ A job's environment, later entries winning: the worker's (that of the `pp queue add` that started it, without an
49
+ agent session's identity); PATH, VIRTUAL_ENV, CONDA_PREFIX and CONDA_DEFAULT_ENV from the shell that added the job;
50
+ the project's [queue] env; add's --env; then PP_QUEUE, PP_QUEUE_JOB, PP_QUEUE_RCFILE and CUDA_VISIBLE_DEVICES. A
51
+ variable you export before `pp queue add` reaches the job only through the worker that add started, so pass a
52
+ token or a secret with --env or the project's env.
53
+
54
+ Timeouts and retries
55
+ --timeout DUR the worker SIGTERMs the job's process group after DUR (90s, 30m, 2h, 1d) and SIGKILLs
56
+ it 10s later; the attempt fails with reason "timeout", and what that comes to is
57
+ under "How a job ends" below. `none` (or 0): no timeout, even when the queue has a
58
+ default one
59
+ --retries N re-run a failure up to N more times, with the SAME id: back to the queue after
60
+ --retry-delay DUR (default 30s, doubling each attempt, at most 1h). A job waiting out its delay never
61
+ holds up the queue. `wait` and `log -f` follow it across attempts.
62
+ --retry-if REGEX retry only failures whose reason ("exit 1", "timeout", "lost", ...) or last 200 log lines
63
+ match (^ and $ anchor per line); any other failure is final at once (wait, watch
64
+ and show say "not retried" and why). `--retry-if '^timeout$'` matches an attempt that
65
+ wrote no code of its own. Unset: every failure is retried.
66
+
67
+ Hooks, on the FINAL outcome of a job
68
+ --on-fail CMD a shell command run when the job has failed for good
69
+ --on-done CMD ... when it succeeded
70
+ A hook never fires between retries, and never for a drop. Which hook an outcome owes is under "How a job
71
+ ends" below.
72
+ `none` turns a hook off. Hooks run detached in the job's directory with PP_QUEUE PP_QUEUE_JOB
73
+ PP_QUEUE_EVENT (failed|done) PP_QUEUE_RC PP_QUEUE_REASON PP_QUEUE_LOG PP_QUEUE_CWD PP_QUEUE_TAG; their output
74
+ goes to <queue>/hooks/<id>-<event>.log.
75
+
76
+ Per project, from the nearest .pp.toml up from the job's directory:
77
+
78
+ [queue]
79
+ runner = "docker/run.sh" # put before every command added from this project (relative to the
80
+ # project root); --no-runner skips it
81
+ env = { HF_HUB_OFFLINE = "1" } # added to each job's environment; add's --env wins
82
+
83
+ The job runs in the directory you queued it from (or --cwd), as `RUNNER CMD...`. The runner gets env and
84
+ CUDA_VISIBLE_DEVICES (the card --gpu-gb reserved) in its own environment. A container runner must pass them in
85
+ (docker run -e CUDA_VISIBLE_DEVICES -e HF_HUB_OFFLINE ...), or the job sees every GPU and none of env.
86
+
87
+ Resources: what a job declares (--gpu-gb, --cpu, --mem-gb, --need NAME=N; every job takes slots = 1) must be
88
+ provided by a pool and fit in EVERY pool that provides it. Shared pools count all queues' running jobs; a
89
+ queue's own pools count only its jobs, so a queue can be held to a share of a shared pool:
90
+
91
+ [resources] # SHARED by every queue
92
+ gpu_gb = "auto" # per card: total - 2 GB, and live free memory from nvidia-smi (default)
93
+ cpu = "auto" # the machine's CPUs (default)
94
+ mem_gb = "auto" # MemTotal (default)
95
+ license = 4 # a counter every queue draws from: --need license=1
96
+ nodes = { probe = "sinfo -h -t idle -o %D", every = "1m", settle = "2m" }
97
+ # a probe: the amount FREE now (first number the command prints), cached for
98
+ # `every`; jobs launched less than `settle` before a reading are subtracted
99
+ # from it. Add max = N to also cap what pp's jobs may hold.
100
+ [queue.default.resources] # this queue's OWN
101
+ slots = 3 # (default)
102
+ cpu = 16 # this queue may use 16 of the shared CPUs
103
+ gpu_gb = 40 # ... and hold at most 40 GB per card
104
+ [queue.dl.resources]
105
+ net = 2 # a counter only dl has: --need net=1
106
+ A pool is a number or { max, probe, every, settle }; `false` removes a default. settle is 30s unless set, for
107
+ gpu_gb and every probe: a job launched less than that before a reading may not show in it yet, so its
108
+ reservation is subtracted from the reading.
109
+
110
+ Config, ~/.config/pp/config.toml: a table per queue, re-read by its worker every loop, with no restart
111
+ needed. An unknown key or a bad value is an error, shown by `pp queue`; while it lasts the worker launches nothing, but
112
+ still records how running jobs end and enforces their timeouts. So does an empty file, which is what a file
113
+ rewritten in place is for a moment; remove it to use the defaults:
114
+
115
+ [queue.default]
116
+ timeout = "6h" # defaults for jobs added to this queue (add's flags win)
117
+ retries = 1
118
+ retry_delay = "1m"
119
+ retry_if = "Connection reset|503"
120
+ on_fail = "notify-send 'pp job failed'" # a shell command; default: none
121
+ on_done = "none"
122
+ idle_exit = 600 # the worker exits after this long with nothing queued or running
123
+ poll = 2 # worker loop period, seconds
124
+ backfill = false # let later jobs that fit start while the head waits
125
+
126
+ A job runs in its own process group, inside an `sh` that writes the job's exit code to a file of that attempt's
127
+ own when the command ends. So the exit code outlives the worker, and a restarted worker adopts running jobs
128
+ instead of losing them. That `sh` is also what says the job is running, for as long as it runs; the command
129
+ itself reads /dev/null, so a process the command leaves behind does not keep the job running. The caller's
130
+ agent session (if any) is recorded as the job's owner and never passed to the job.
131
+
132
+ A queue's worker is what signals that queue's jobs, and only a worker holds the queue's worker claim. `pp
133
+ queue kill` writes a stop on the job and waits for the outcome that is recorded for it. It makes sure the
134
+ queue has a worker: it wakes the one that is up, or starts one where none is. So a kill with no worker up
135
+ starts one, and that worker carries out the stop and launches the queue's queued jobs. Two kills of one queue
136
+ come to the same thing: the one worker carries out both stops.
137
+
138
+ `pp queue worker stop` stops the worker that is up. It is not a pause: the next command that needs a worker
139
+ starts one.
140
+
141
+ A timeout is the worker writing a stop on the job itself, and nothing waits for it, so each step of it goes
142
+ out at the queue's poll. The worker signals the job's process group, escalates to SIGKILL once the grace is
143
+ up, and records how the job ended. Anything left in that group when the run is over goes with it, so a
144
+ command that held the signal back does not outlive its job. One actor ends a job, and a job that ended on its
145
+ own meanwhile keeps its exit code.
146
+
147
+ How a job ends, whatever reached it. The rules are tried in the order below, and the first that holds for a
148
+ job is what happens to it.
149
+
150
+ While a run of the job is live its exit code says nothing about how the job ended: the job's own `sh`
151
+ holds the run, and the command can write PP_QUEUE_RCFILE itself. What is done about such a job is what
152
+ its stop asks for.
153
+
154
+ A signal pp was refused is recorded as denied, and nothing more goes out for that stop.
155
+ A stop nobody has signalled yet goes out as SIGTERM to the job's process group.
156
+ A stop whose grace is up is escalated to SIGKILL, once.
157
+ A stop inside its grace, or SIGKILLed already, is waited for.
158
+ A job with no stop whose own timeout has run out gets a timeout's stop.
159
+ A job with no stop whose time is not up is left as it is.
160
+
161
+ Once the run is over the exit code the attempt wrote is the command's last word, and the job ends here. A
162
+ stop someone asked for is never retried.
163
+
164
+ A job a kill's signal stopped is recorded "killed" and fires no hook, unless it wrote its own exit code
165
+ as the kill arrived, and then it keeps that code and fires the hook for it.
166
+ A job a kill wrote a stop for that no signal of pp's ever reached ends with its own outcome, the exit
167
+ code it wrote or the signal it died of, and fires the hook that outcome owes.
168
+ An exit code of 0 is done, and on_done fires.
169
+ A timeout's stop pp signalled or was refused, with no exit code of the attempt's own, is a timeout, which
170
+ may be retried; on a final timeout, on_fail fires.
171
+ Anything else is the job's own failure, the exit code it wrote or the signal it died of, retried while an
172
+ attempt is left and --retry-if matches it, and on_fail fires on the last.
173
+
174
+ One run of a job carries one stop. A kill's ask on a timeout's stop makes it a kill, a second ask keeps what
175
+ has gone out and only shortens the grace, and a refused stop is asked for again. `show` and --json say whose
176
+ stop it is, when it was asked for, what has gone out for it and whether pp was refused, so nobody waits for a
177
+ stop that is not going to be carried out.
178
+
179
+ A job's record names the files that are its own: its log, the exit code each attempt wrote, and the log of
180
+ each hook that ran for it. `drop` and `clean` take those with the record, so neither leaves a file behind that
181
+ nothing names. State: ~/.local/state/pp/queue/<name>/.
182
+ """
183
+ from __future__ import annotations
184
+
185
+ import os
186
+ import shlex
187
+ import shutil
188
+
189
+ from pp.lib import config, jobq, ui
190
+
191
+ PROJECT_KEYS = ("runner", "env")
192
+
193
+
194
+ def _add_args(p):
195
+ p.add_argument("--no-runner", action="store_true", help="ignore the project's [queue] runner")
196
+
197
+
198
+ def _runner(spec, root: str, path: str | None) -> list[str]:
199
+ """argv of the project's runner; refuses (exit 2) one that could only fail at launch time, hours later."""
200
+ if not spec:
201
+ return []
202
+ where = f"{root}/.pp.toml"
203
+ if isinstance(spec, str):
204
+ argv = shlex.split(spec)
205
+ elif isinstance(spec, list) and all(isinstance(x, str) for x in spec):
206
+ argv = list(spec)
207
+ else:
208
+ raise config.ConfigError(f"{where}: [queue] runner must be a command line or a list of strings")
209
+ if not argv:
210
+ return []
211
+ exe = os.path.expanduser(argv[0])
212
+ if not os.path.isabs(exe) and os.sep in exe:
213
+ exe = os.path.join(root, exe)
214
+ if os.sep not in exe:
215
+ if not shutil.which(exe, path=path):
216
+ ui.die(f"queue: runner {exe} (from {where}) is not on PATH; or pass --no-runner", 2)
217
+ elif not os.path.exists(exe):
218
+ ui.die(f"queue: runner {exe} (from {where}) does not exist; or pass --no-runner", 2)
219
+ elif not os.access(exe, os.X_OK):
220
+ ui.die(f"queue: runner {exe} (from {where}) is not executable (chmod +x); or pass --no-runner", 2)
221
+ return [exe] + argv[1:]
222
+
223
+
224
+ def _prepare(job: dict, a) -> None:
225
+ """The project's [queue] runner and env; a job's own --env wins over the project's."""
226
+ root, data = config.find_project(job["cwd"])
227
+ if not root:
228
+ return
229
+ where = f"{root}/.pp.toml"
230
+ pcfg = data.get("queue", {})
231
+ if not isinstance(pcfg, dict):
232
+ raise config.ConfigError(f"{where}: queue must be a table ([queue] runner = ..., env = {{...}})")
233
+ config.check_keys(pcfg, f"{where} [queue]", PROJECT_KEYS)
234
+ env = pcfg.get("env", {})
235
+ if not isinstance(env, dict):
236
+ raise config.ConfigError(f"{where}: [queue] env must be a table, e.g. env = {{ HF_HUB_OFFLINE = \"1\" }}")
237
+ if not getattr(a, "no_runner", False):
238
+ job["runner"] = _runner(pcfg.get("runner"), root, (job.get("shell_env") or {}).get("PATH"))
239
+ job["env"] = {**{str(k): str(v) for k, v in env.items()}, **job["env"]}
240
+
241
+
242
+ def main(argv: list[str]) -> int:
243
+ return jobq.run_cli(argv, "queue", "default", __doc__, prepare_job=_prepare, add_args=_add_args)
pp/lib/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Shared helpers for pp commands. Commands import from here, never from each other."""
pp/lib/config.py ADDED
@@ -0,0 +1,110 @@
1
+ """Configuration: one global TOML file plus an optional per-project `.pp.toml`.
2
+
3
+ Global ~/.config/pp/config.toml holds a table per command: [queue.NAME] ... [http] ... [batch.presets]
4
+ Project .pp.toml in the project root (nearest one walking up from cwd) holds [promote], [queue], ...
5
+
6
+ Who owns which table, and what rejects an unknown key in it:
7
+
8
+ config.toml [resources], [queue.NAME] queue jobq (a queue's table and [resources])
9
+ [http] http http's KEYS
10
+ [batch] batch batch._presets
11
+ .pp.toml name promote and queue (the releases dir, a job's project)
12
+ [promote] promote promote._checked
13
+ [queue] queue queue._prepare
14
+ [NAME] of your own your command; pp ignores tables it does not own
15
+
16
+ Both are read-only to pp; edit them by hand. A malformed file is a loud error naming the file, not a silent default.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import re
22
+ import tomllib
23
+
24
+ from pp.lib import paths, proc
25
+
26
+ PROJECT_FILE = ".pp.toml"
27
+
28
+
29
+ class ConfigError(Exception):
30
+ pass
31
+
32
+
33
+ def _read(path: str, empty_ok: bool = True) -> dict:
34
+ """The parsed file; {} when it is absent. With empty_ok False an empty file is an error: rewritten in place
35
+ (`cat >`, some editors), a file is empty for a moment, and a reader acting on what it says must not take that
36
+ moment for "no settings"."""
37
+ try:
38
+ with open(path, "rb") as f:
39
+ raw = f.read()
40
+ except FileNotFoundError:
41
+ return {}
42
+ if not raw and not empty_ok:
43
+ raise ConfigError(f"{path} is empty: being saved, or remove it to use the defaults")
44
+ try:
45
+ return tomllib.loads(raw.decode())
46
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError) as e:
47
+ raise ConfigError(f"{path}: {e}") from None
48
+
49
+
50
+ def global_path() -> str:
51
+ return os.path.join(paths.config_dir(), "config.toml")
52
+
53
+
54
+ def load(section: str | None = None, empty_ok: bool = True) -> dict:
55
+ """The global config, or one table of it ({} when absent; a ConfigError when it is there but not a table)."""
56
+ data = _read(global_path(), empty_ok)
57
+ if not section:
58
+ return data
59
+ v = data.get(section, {})
60
+ if not isinstance(v, dict):
61
+ raise ConfigError(f"{global_path()}: {section} = {v!r}: [{section}] must be a table")
62
+ return v
63
+
64
+
65
+ def check_keys(table: dict, where: str, known) -> None:
66
+ """A key pp does not read is a typo until proven otherwise: a ConfigError naming it, never a silent default."""
67
+ bad = sorted(set(table) - set(known))
68
+ if bad:
69
+ raise ConfigError(f"{where}: unknown {', '.join(bad)} (known: {', '.join(known)})")
70
+
71
+
72
+ def parse_duration(v, default_unit: str = "s") -> float | None:
73
+ """90 / 90s / 30m / 2h / 1d (a bare number is default_unit) -> seconds; None for None/''; ValueError otherwise."""
74
+ if v is None or v == "":
75
+ return None
76
+ if isinstance(v, (int, float)) and not isinstance(v, bool):
77
+ return float(v) * {"s": 1, "m": 60, "h": 3600, "d": 86400}[default_unit]
78
+ m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([smhd]?)\s*", str(v))
79
+ if not m:
80
+ raise ValueError(f"'{v}' is not a duration like 90s, 30m, 2h, 1d")
81
+ return float(m[1]) * {"s": 1, "m": 60, "h": 3600, "d": 86400}[m[2] or default_unit]
82
+
83
+
84
+ def find_project(start: str | None = None) -> tuple[str | None, dict]:
85
+ """(root, parsed .pp.toml) of the nearest project file walking up from start; (None, {}) when there is none."""
86
+ d = os.path.abspath(start or os.getcwd())
87
+ while True:
88
+ f = os.path.join(d, PROJECT_FILE)
89
+ if os.path.isfile(f):
90
+ return d, _read(f)
91
+ parent = os.path.dirname(d)
92
+ if parent == d:
93
+ return None, {}
94
+ d = parent
95
+
96
+
97
+ def git_root(start: str | None = None) -> str | None:
98
+ try:
99
+ out = proc.git("-C", start or os.getcwd(), "rev-parse", "--show-toplevel", group="own", timeout=10)
100
+ except OSError:
101
+ return None
102
+ if out.returncode != 0:
103
+ return None
104
+ return out.stdout.strip() or None
105
+
106
+
107
+ def project_name(root: str, data: dict | None = None) -> str:
108
+ """A project's name: `name` at the top of its .pp.toml, else its directory name."""
109
+ data = data if data is not None else _read(os.path.join(root, PROJECT_FILE))
110
+ return str(data.get("name") or os.path.basename(os.path.normpath(root)))