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/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)))
|