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/lock.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""The two lock constructs, and nothing else in pp takes a lock (tests/test_constructs.py reads the source and
|
|
2
|
+
fails on a call that does):
|
|
3
|
+
|
|
4
|
+
locked(path) how pp keeps two processes out of each other's way for the length of a scope
|
|
5
|
+
Claim.take(path) how one process says it is the one doing something, for as long as it lives
|
|
6
|
+
|
|
7
|
+
`deferring()` is the part of `locked` that holds the signals back, for a scope that has to be whole where no lock
|
|
8
|
+
is involved. Each docstring is the contract.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import errno
|
|
13
|
+
import fcntl
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import signal
|
|
17
|
+
import struct
|
|
18
|
+
import threading
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Busy(Exception):
|
|
23
|
+
"""What `locked(block=False)` raises when another holder has the lock. The caller turns it into its own
|
|
24
|
+
message, since only the caller knows what is held and what to tell the user."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_files: dict[str, list] = {} # lock file -> [thread lock, fd, depth, shared]
|
|
28
|
+
_mutex = threading.Lock()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@contextmanager
|
|
32
|
+
def locked(path: str, *, block: bool = True, defer: bool = False, shared: bool = False):
|
|
33
|
+
"""Hold the lock file at path for the block, and release it on the way out.
|
|
34
|
+
|
|
35
|
+
The file is created if it is missing, and its directory with it; nothing is ever written in it. One holder
|
|
36
|
+
at a time: another process waits for it, and with block=False gets `Busy` at once instead. The thread
|
|
37
|
+
holding it may take it again, so a scope nested in another is safe; another thread of this process waits
|
|
38
|
+
like another process does.
|
|
39
|
+
|
|
40
|
+
With shared, several processes read at once, and a holder that wants it to itself waits for them. Two
|
|
41
|
+
threads of one process still take turns: the thread lock above the file is taken whether the hold is
|
|
42
|
+
shared or not, because the helper keeps one fd per path, and one fd holds one kind of lock. A scope that
|
|
43
|
+
writes cannot start inside one that reads: the hold would have to be let go of to be made exclusive, and
|
|
44
|
+
what was read before it would then be older than the store. That raises instead.
|
|
45
|
+
|
|
46
|
+
With defer, the signals `deferring` holds back are delivered when the block ends, to the handlers in
|
|
47
|
+
place before it, so a change made under the lock is never left half done. A Ctrl-C while waiting for the
|
|
48
|
+
lock still stops the command.
|
|
49
|
+
|
|
50
|
+
No code holds two of these locks at once, which is a convention and not a check: `pp promote` takes the
|
|
51
|
+
registry's lock and releases it before it takes a target's own (`pp/commands/promote.py`), and one that
|
|
52
|
+
ever has to hold both takes the registry's first. One lock is taken inside another in pp, and only one: a
|
|
53
|
+
job's run claim inside the store's transaction, which `pp/lib/jobq.py`'s `txn` states, and a claim is
|
|
54
|
+
taken without waiting.
|
|
55
|
+
"""
|
|
56
|
+
path = os.path.abspath(path)
|
|
57
|
+
with _mutex:
|
|
58
|
+
state = _files.setdefault(path, [threading.RLock(), None, 0, False])
|
|
59
|
+
if not state[0].acquire(blocking=block):
|
|
60
|
+
raise Busy(path)
|
|
61
|
+
try:
|
|
62
|
+
if state[2] == 0:
|
|
63
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
64
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
65
|
+
how = fcntl.LOCK_SH if shared else fcntl.LOCK_EX
|
|
66
|
+
try:
|
|
67
|
+
fcntl.flock(fd, how if block else how | fcntl.LOCK_NB)
|
|
68
|
+
except BlockingIOError:
|
|
69
|
+
os.close(fd)
|
|
70
|
+
raise Busy(path) from None
|
|
71
|
+
except BaseException:
|
|
72
|
+
os.close(fd)
|
|
73
|
+
raise
|
|
74
|
+
state[1], state[3] = fd, shared
|
|
75
|
+
elif state[3] and not shared:
|
|
76
|
+
raise RuntimeError(f"{path} is held for reading: a scope that writes cannot start inside one that "
|
|
77
|
+
"reads, since it would have to let the lock go to take it alone")
|
|
78
|
+
state[2] += 1
|
|
79
|
+
try:
|
|
80
|
+
if defer:
|
|
81
|
+
with deferring():
|
|
82
|
+
yield
|
|
83
|
+
else:
|
|
84
|
+
yield
|
|
85
|
+
finally:
|
|
86
|
+
state[2] -= 1
|
|
87
|
+
if state[2] == 0:
|
|
88
|
+
os.close(state[1])
|
|
89
|
+
state[1] = None
|
|
90
|
+
finally:
|
|
91
|
+
state[0].release()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
RECORD_MAX = 65536 # how much of a claim file is read: a record is a few hundred bytes
|
|
95
|
+
MAKE_TRIES = 3 # names `Claim.make` takes where a sweep of unheld claims forgets one as it takes it
|
|
96
|
+
|
|
97
|
+
# A claim is an open file description lock (fcntl(2), F_OFD_SETLK): like flock it belongs to the open file, is
|
|
98
|
+
# shared by every fd a fork or a dup made of it, and goes when the last of them closes. Unlike flock, F_OFD_GETLK
|
|
99
|
+
# asks whether someone holds one without taking anything, so asking can never stand in a taker's way. Python's
|
|
100
|
+
# fcntl names these only where its build knew them; the numbers are the same on every Linux.
|
|
101
|
+
_OFD_GETLK = getattr(fcntl, "F_OFD_GETLK", 36)
|
|
102
|
+
_OFD_SETLK = getattr(fcntl, "F_OFD_SETLK", 37)
|
|
103
|
+
_FLOCK = struct.Struct("=hh4xqqi4x") # struct flock on 64-bit Linux: type, whence, start, len, pid
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _whole_file(kind: int) -> bytes:
|
|
107
|
+
"""A struct flock for the whole file, of kind F_WRLCK or F_UNLCK."""
|
|
108
|
+
return _FLOCK.pack(kind, os.SEEK_SET, 0, 0, 0)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _same_file(path: str, fd: int) -> bool:
|
|
112
|
+
"""Whether path still names the file this fd holds. A name removed while this fd was being locked leaves
|
|
113
|
+
the lock on a file no name leads to, which no reader can ever find."""
|
|
114
|
+
try:
|
|
115
|
+
named, held = os.stat(path), os.fstat(fd)
|
|
116
|
+
except OSError:
|
|
117
|
+
return False
|
|
118
|
+
return (named.st_dev, named.st_ino) == (held.st_dev, held.st_ino)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Claim:
|
|
122
|
+
"""A claim on a path: one process says it is the one doing something, and holds the claim while it lives.
|
|
123
|
+
|
|
124
|
+
`Claim.take(path, record)` takes it, or gives None back because another holder has it; the holder keeps the
|
|
125
|
+
claim until it releases it or ends. `Claim.make(path, record)` takes one on a name nobody else ever takes,
|
|
126
|
+
locked before the name is visible. `Claim.holder(path)` asks who holds it, without waiting and without
|
|
127
|
+
taking it away. One claim per thing pp does at most once at a time: a queue's worker, one run of one job,
|
|
128
|
+
one run of one probe.
|
|
129
|
+
|
|
130
|
+
c = Claim.take(path, {"pid": os.getpid()}) # None: someone else is doing it
|
|
131
|
+
if c is None:
|
|
132
|
+
return
|
|
133
|
+
with c:
|
|
134
|
+
... # and the claim is let go of here
|
|
135
|
+
|
|
136
|
+
The kernel holds it, as a lock on the open file, and lets go of it when the holder's last file description
|
|
137
|
+
on it closes: at a clean exit, on an exception, on a SIGKILL, and when the machine loses power. So nothing
|
|
138
|
+
has to be cleaned up after a holder that died, and no pid and no start time decide whether the holder is
|
|
139
|
+
alive: getting the claim means there is no holder, whatever the file says, and failing to get it means
|
|
140
|
+
there is one.
|
|
141
|
+
|
|
142
|
+
What the file holds is the record of who holds it now. It is written under the claim: `take` locks the
|
|
143
|
+
file, empties it and writes the record, in that order, and `release` empties it again. So an orderly
|
|
144
|
+
release leaves no record, and the bytes a reader finds between a take's lock and its write are either
|
|
145
|
+
none or the record of a holder that was killed. A reader that has to tell one holder from another looks
|
|
146
|
+
again until the record names a process that is running. A holder that writes its record again writes over
|
|
147
|
+
every byte of the one before it in one call, so a reader finds one record or the other, never half of
|
|
148
|
+
each.
|
|
149
|
+
|
|
150
|
+
The holder's file description is close-on-exec, so a process the holder starts does not keep the claim
|
|
151
|
+
alive after the holder ends. A process that is MEANT to hold it is given the file description itself,
|
|
152
|
+
which carries the lock: a job's wrapper gets it as its stdin, and from then on the job's run is what holds
|
|
153
|
+
the job's claim.
|
|
154
|
+
|
|
155
|
+
The file stays while the thing it is about exists, since a reader may be about to ask about it, and removing
|
|
156
|
+
it would let the next taker create another file under the same name and hold a second claim on the same
|
|
157
|
+
thing. A claim file left by a holder that has gone costs one small file and reads as free. `forget` removes
|
|
158
|
+
the file of a thing that is gone for good, and is the only way a claim file is removed.
|
|
159
|
+
|
|
160
|
+
`take` creates the file and then locks it, so for a moment a file exists that nobody holds yet. A name
|
|
161
|
+
whose claim file is swept away when nobody holds it is taken with `make` instead, which locks the file
|
|
162
|
+
before the name is visible.
|
|
163
|
+
|
|
164
|
+
Over NFS this is as strong as the server's record locks, which may be weaker than they are locally; pp's
|
|
165
|
+
state directory is a local directory.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(self, path: str, fd: int):
|
|
169
|
+
self.path = path
|
|
170
|
+
self.fd: int | None = fd
|
|
171
|
+
self._held = 0 # bytes of the file this holder has written, which the next write covers whole
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def take(cls, path: str, record: dict | None = None) -> Claim | None:
|
|
175
|
+
"""Take the claim on path and keep it; None when another holder has it. Nothing waits here.
|
|
176
|
+
|
|
177
|
+
record says who holds it, for whoever asks. None leaves the file empty, for a claim whose holder writes
|
|
178
|
+
its own record (a job's wrapper) or whose holder nobody asks about (a probe). A claim taken with no
|
|
179
|
+
record is also the one a release leaves alone, which is what a handed-on file description needs."""
|
|
180
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
181
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
182
|
+
try:
|
|
183
|
+
fcntl.fcntl(fd, _OFD_SETLK, _whole_file(fcntl.F_WRLCK))
|
|
184
|
+
except OSError as e:
|
|
185
|
+
os.close(fd)
|
|
186
|
+
if e.errno in (errno.EAGAIN, errno.EACCES): # what F_OFD_SETLK says when another holder has it
|
|
187
|
+
return None
|
|
188
|
+
raise
|
|
189
|
+
except BaseException:
|
|
190
|
+
os.close(fd)
|
|
191
|
+
raise
|
|
192
|
+
c = cls(path, fd)
|
|
193
|
+
try:
|
|
194
|
+
os.ftruncate(fd, 0) # what a holder that has gone wrote is not this holder's record
|
|
195
|
+
if record is not None:
|
|
196
|
+
c.write(record)
|
|
197
|
+
except BaseException:
|
|
198
|
+
c.release()
|
|
199
|
+
raise
|
|
200
|
+
return c
|
|
201
|
+
|
|
202
|
+
@classmethod
|
|
203
|
+
def make(cls, path: str, record: dict | None = None) -> Claim:
|
|
204
|
+
"""Take the claim on a name nobody else ever takes, locked before that name is visible.
|
|
205
|
+
|
|
206
|
+
The file is created under a name of this call's own, locked, written, and renamed into place. So a
|
|
207
|
+
file that exists at path is one its holder already holds, and a sweeper that finds one unheld knows
|
|
208
|
+
its holder is gone: it can never take the claim away from a holder that is in the middle of taking
|
|
209
|
+
it. `take` cannot promise that, since the name it takes is claimed again and again and its file has
|
|
210
|
+
to outlive each holder.
|
|
211
|
+
|
|
212
|
+
For a name that carries its holder's own identity, which nobody else ever takes: a `wait`'s lease.
|
|
213
|
+
Nothing competes for it, so this gives back a claim or raises what the file system raised, and never
|
|
214
|
+
None.
|
|
215
|
+
|
|
216
|
+
The name it creates ends in `.claim`, like the name it renames to, so a sweep of unheld claims
|
|
217
|
+
forgets what a holder killed before the rename left behind: nobody holds that file, and no name of
|
|
218
|
+
pp's is ever claimed again. A sweep that forgets that name in the moment before the lock is taken is
|
|
219
|
+
what the look after the lock is for: this takes another name where the one it locked has been
|
|
220
|
+
forgotten, since a lock on a file no name leads to is a claim nobody can ever find."""
|
|
221
|
+
d = os.path.dirname(path) or "."
|
|
222
|
+
os.makedirs(d, exist_ok=True)
|
|
223
|
+
for _ in range(MAKE_TRIES):
|
|
224
|
+
tmp = os.path.join(d, f".{os.path.basename(path)}.{os.getpid()}.{os.urandom(4).hex()}.new.claim")
|
|
225
|
+
c = cls(path, os.open(tmp, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o644))
|
|
226
|
+
try:
|
|
227
|
+
fcntl.fcntl(c.fd, _OFD_SETLK, _whole_file(fcntl.F_WRLCK)) # this name is nobody else's: free
|
|
228
|
+
if not _same_file(tmp, c.fd):
|
|
229
|
+
c.release()
|
|
230
|
+
continue # a sweep found that name unheld and forgot it: take another
|
|
231
|
+
if record is not None:
|
|
232
|
+
c.write(record)
|
|
233
|
+
os.rename(tmp, path) # and from here the name is this holder's, already locked
|
|
234
|
+
except BaseException:
|
|
235
|
+
c.release()
|
|
236
|
+
try:
|
|
237
|
+
os.remove(tmp)
|
|
238
|
+
except OSError:
|
|
239
|
+
pass
|
|
240
|
+
raise
|
|
241
|
+
return c
|
|
242
|
+
raise FileNotFoundError(errno.ENOENT, f"{path}: every name this took was forgotten as it took it")
|
|
243
|
+
|
|
244
|
+
def write(self, record: dict) -> None:
|
|
245
|
+
"""Replace the record in the claim file. Only the holder writes it, in one call that covers every byte it
|
|
246
|
+
wrote before, so a reader finds one whole record. The record ends at its newline, and the spaces that may
|
|
247
|
+
follow it are nothing to a reader of JSON."""
|
|
248
|
+
data = json.dumps(record).encode() + b"\n"
|
|
249
|
+
data += b" " * max(0, self._held - len(data))
|
|
250
|
+
os.pwrite(self.fd, data, 0)
|
|
251
|
+
self._held = len(data)
|
|
252
|
+
|
|
253
|
+
def release(self) -> None:
|
|
254
|
+
"""Let the claim go, and take this holder's record with it: the file is emptied before the descriptor
|
|
255
|
+
closes, so an orderly release leaves no record and the only record a reader ever finds is the one its
|
|
256
|
+
holder wrote. A holder that was killed leaves its record behind, naming a process that is gone.
|
|
257
|
+
|
|
258
|
+
A claim whose record is written by whoever is handed the file description (a job's wrapper) is taken
|
|
259
|
+
with no record, so this holder has nothing to take away and that writer's record stays. Closing the
|
|
260
|
+
file is what releases the claim, so an exit releases it too; releasing twice is not an error."""
|
|
261
|
+
if self.fd is not None:
|
|
262
|
+
try:
|
|
263
|
+
if self._held:
|
|
264
|
+
os.ftruncate(self.fd, 0)
|
|
265
|
+
finally:
|
|
266
|
+
os.close(self.fd)
|
|
267
|
+
self.fd = None
|
|
268
|
+
self._held = 0
|
|
269
|
+
|
|
270
|
+
def __enter__(self) -> Claim:
|
|
271
|
+
return self
|
|
272
|
+
|
|
273
|
+
def __exit__(self, *exc) -> None:
|
|
274
|
+
self.release()
|
|
275
|
+
|
|
276
|
+
@classmethod
|
|
277
|
+
def forget(cls, path: str) -> None:
|
|
278
|
+
"""Remove the claim file of a thing that is gone and whose name is never claimed again: a job whose
|
|
279
|
+
record has been taken away, a `wait` whose lease carries its own pid. Every removal of a claim file
|
|
280
|
+
goes through here. A name that may be claimed again keeps its file, since removing it would let the
|
|
281
|
+
next taker create another file under that name and hold a second claim on the same thing."""
|
|
282
|
+
try:
|
|
283
|
+
os.remove(path)
|
|
284
|
+
except OSError:
|
|
285
|
+
pass
|
|
286
|
+
|
|
287
|
+
@classmethod
|
|
288
|
+
def holder(cls, path: str) -> dict | None:
|
|
289
|
+
"""The record of whoever holds the claim on path, or None when nobody holds it. Asking takes nothing: it
|
|
290
|
+
never waits, and never stands in the way of a holder or of a process taking the claim.
|
|
291
|
+
|
|
292
|
+
An empty record means the claim is held by a holder that has not written one yet, which is a holder
|
|
293
|
+
starting: it is alive, and it names nothing yet. A record naming a process that is gone is the record
|
|
294
|
+
a holder that was killed left, read while the next holder is taking the claim."""
|
|
295
|
+
try:
|
|
296
|
+
fd = os.open(path, os.O_RDONLY)
|
|
297
|
+
except OSError:
|
|
298
|
+
return None
|
|
299
|
+
try:
|
|
300
|
+
held = _FLOCK.unpack(fcntl.fcntl(fd, _OFD_GETLK, _whole_file(fcntl.F_WRLCK)))[0]
|
|
301
|
+
if held == fcntl.F_UNLCK:
|
|
302
|
+
return None # nobody holds it, whatever the file says
|
|
303
|
+
try:
|
|
304
|
+
rec = json.loads(os.pread(fd, RECORD_MAX, 0) or b"{}")
|
|
305
|
+
except ValueError:
|
|
306
|
+
return {}
|
|
307
|
+
return rec if isinstance(rec, dict) else {}
|
|
308
|
+
finally:
|
|
309
|
+
os.close(fd)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@contextmanager
|
|
313
|
+
def deferring():
|
|
314
|
+
"""The four signals whose default action ends the process, namely INT, TERM, HUP and QUIT, are delivered
|
|
315
|
+
when the block ends, to the handlers in place before it. This is the one place that set is named; every
|
|
316
|
+
other scope says "the signals lock.deferring holds back". Python runs signal handlers in the main thread
|
|
317
|
+
only, so only the main thread needs this (and may set them).
|
|
318
|
+
|
|
319
|
+
What `locked(defer=True)` holds a change under the lock with, and what a scope with no lock of its own
|
|
320
|
+
uses to be whole: a few renames that go through temporary names, say. Keep the block short, since a Ctrl-C
|
|
321
|
+
waits for it."""
|
|
322
|
+
if threading.current_thread() is not threading.main_thread():
|
|
323
|
+
yield
|
|
324
|
+
return
|
|
325
|
+
got: list[int] = []
|
|
326
|
+
old = {s: signal.getsignal(s)
|
|
327
|
+
for s in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP, signal.SIGQUIT)}
|
|
328
|
+
for s in old:
|
|
329
|
+
signal.signal(s, lambda n, _f: got.append(n))
|
|
330
|
+
try:
|
|
331
|
+
yield
|
|
332
|
+
finally:
|
|
333
|
+
for s, h in old.items():
|
|
334
|
+
signal.signal(s, h if h is not None else signal.SIG_DFL)
|
|
335
|
+
if got:
|
|
336
|
+
from pp.lib import proc # here and not at the top of the file: pp.lib.paths imports this one
|
|
337
|
+
for n in got:
|
|
338
|
+
proc.raise_to_self(n) # now: raises KeyboardInterrupt, ends the process, or sets a flag
|
pp/lib/paths.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Where pp keeps things. XDG locations, each overridable by a PP_* variable (tests point them at a temp dir).
|
|
2
|
+
|
|
3
|
+
config ~/.config/pp PP_CONFIG_DIR config.toml, commands/ (user commands)
|
|
4
|
+
state ~/.local/state/pp PP_STATE_DIR queues, journals, logs, per command: state_dir("queue")
|
|
5
|
+
data ~/.local/share/pp PP_DATA_DIR releases made by `pp promote`, plugins added by `pp cmd add`
|
|
6
|
+
|
|
7
|
+
`json_txn` is the read-modify-write a JSON record goes through; its docstring is the contract. `write_json` and
|
|
8
|
+
`write_text` replace a whole file in one step, and `staging` hands out a name no other writer picks.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import copy
|
|
13
|
+
import itertools
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import secrets
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import threading
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
|
|
22
|
+
from pp.lib.lock import locked
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def expand(p: str) -> str:
|
|
26
|
+
"""~ and $VARS expanded, made absolute. Symlinks are kept (use os.path.realpath when identity matters)."""
|
|
27
|
+
return os.path.abspath(os.path.expanduser(os.path.expandvars(p)))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _xdg(env: str, xdg: str, fallback: str) -> str:
|
|
31
|
+
if os.environ.get(env):
|
|
32
|
+
return expand(os.environ[env])
|
|
33
|
+
return os.path.join(expand(os.environ.get(xdg) or fallback), "pp")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def config_dir() -> str:
|
|
37
|
+
return _xdg("PP_CONFIG_DIR", "XDG_CONFIG_HOME", "~/.config")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def state_dir(*sub: str, create: bool = True) -> str:
|
|
41
|
+
d = os.path.join(_xdg("PP_STATE_DIR", "XDG_STATE_HOME", "~/.local/state"), *sub)
|
|
42
|
+
if create:
|
|
43
|
+
os.makedirs(d, exist_ok=True)
|
|
44
|
+
return d
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def data_dir(*sub: str, create: bool = True) -> str:
|
|
48
|
+
d = os.path.join(_xdg("PP_DATA_DIR", "XDG_DATA_HOME", "~/.local/share"), *sub)
|
|
49
|
+
if create:
|
|
50
|
+
os.makedirs(d, exist_ok=True)
|
|
51
|
+
return d
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def pp_root() -> str:
|
|
55
|
+
"""The directory holding the running pp package (a source tree or a release)."""
|
|
56
|
+
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
RELEASE_META = ".pp-release.json"
|
|
60
|
+
# `pp promote` writes it into each release it makes: {name, rev, source, mode, dirty, checked, created,
|
|
61
|
+
# created_iso}, plus `failed` (the hook name) once activating that release failed. Readers: promote (rollback, ls
|
|
62
|
+
# and status skip or mark a failed or unchecked release), cmd new (writes a built-in into `source`, never into the
|
|
63
|
+
# snapshot).
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def release_meta(root: str) -> dict | None:
|
|
67
|
+
"""The metadata `pp promote` left in the release at root; None when root is not a release (e.g. a source tree)."""
|
|
68
|
+
try:
|
|
69
|
+
with open(os.path.join(root, RELEASE_META)) as f:
|
|
70
|
+
meta = json.load(f)
|
|
71
|
+
except (OSError, ValueError):
|
|
72
|
+
return None
|
|
73
|
+
return meta if isinstance(meta, dict) else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@contextmanager
|
|
77
|
+
def _replacing(path: str):
|
|
78
|
+
"""Yield a file open for writing, and put it in place of path when the block ends. A block that raises
|
|
79
|
+
leaves path as it was and no temporary file behind."""
|
|
80
|
+
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), prefix=os.path.basename(path) + ".", suffix=".tmp")
|
|
81
|
+
try:
|
|
82
|
+
with os.fdopen(fd, "w") as f:
|
|
83
|
+
yield f
|
|
84
|
+
os.replace(tmp, path)
|
|
85
|
+
except BaseException:
|
|
86
|
+
try:
|
|
87
|
+
os.remove(tmp)
|
|
88
|
+
except OSError:
|
|
89
|
+
pass
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def write_json(path: str, data, **dump) -> None:
|
|
94
|
+
"""Replace path with data as JSON in one step: a reader sees the old file or the new one, never part of one.
|
|
95
|
+
The temporary file is this writer's own, so two writers at once cannot mix their halves; the later rename
|
|
96
|
+
wins, so a read-modify-write goes through json_txn, which holds a lock."""
|
|
97
|
+
with _replacing(path) as f:
|
|
98
|
+
json.dump(data, f, **dump)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def write_text(path: str, s: str) -> None:
|
|
102
|
+
"""Replace path with s in one step, the way write_json replaces a record. The file keeps the mode it had,
|
|
103
|
+
so rewriting one a person edits does not change who can read it; a new file is 0644."""
|
|
104
|
+
try:
|
|
105
|
+
mode = os.stat(path).st_mode & 0o7777
|
|
106
|
+
except OSError:
|
|
107
|
+
mode = 0o644
|
|
108
|
+
with _replacing(path) as f:
|
|
109
|
+
os.fchmod(f.fileno(), mode)
|
|
110
|
+
f.write(s)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
_counter = itertools.count(1) # the number in a staging name; one process hands each out once
|
|
114
|
+
_counting = threading.Lock()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def staging(parent: str, prefix: str) -> str:
|
|
118
|
+
"""A path under parent that no other writer picks: `.staging-<prefix>-<pid>-<number>-<random>`.
|
|
119
|
+
|
|
120
|
+
Two names from one process differ in the number, which is handed out once, so within a process no two can
|
|
121
|
+
be the same. Another process has another pid. The random tail tells this pid from the same pid in another
|
|
122
|
+
namespace or after a wrap. The caller creates the name itself, with a mkdir, an `open(..., "x")`, a symlink
|
|
123
|
+
or a clone, each of which fails on a name that is there, so a name that did repeat would stop the command
|
|
124
|
+
instead of writing over what holds it.
|
|
125
|
+
|
|
126
|
+
Nothing has to be removed before the name is used, and nobody else can remove it between its creation and
|
|
127
|
+
its rename. The leading dot keeps it out of listings that read every entry as a real one."""
|
|
128
|
+
with _counting:
|
|
129
|
+
n = next(_counter)
|
|
130
|
+
return os.path.join(parent, f".staging-{prefix}-{os.getpid()}-{n}-{secrets.token_hex(4)}")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@contextmanager
|
|
134
|
+
def json_txn(path: str, *, lock: str | None = None, **dump):
|
|
135
|
+
"""Read the JSON record at path, hand it over to change, and write it back, all under one lock, so a writer
|
|
136
|
+
that read first never loses what another recorded meanwhile. The lock is `path + ".lock"` unless lock names
|
|
137
|
+
another file. A file that is missing, unreadable, or not a JSON object reads as an empty one.
|
|
138
|
+
|
|
139
|
+
The record is written only when the block ends without raising, and only when it changed: a `ui.die` inside
|
|
140
|
+
is a refusal, and leaves the file byte for byte as it was. The signals `lock.deferring` holds back are
|
|
141
|
+
delivered when the block ends, so a change the block makes elsewhere and the record it writes both land;
|
|
142
|
+
keep the block short, since Ctrl-C waits for it."""
|
|
143
|
+
with locked(lock or path + ".lock", defer=True):
|
|
144
|
+
blank: dict = {}
|
|
145
|
+
try:
|
|
146
|
+
with open(path) as f:
|
|
147
|
+
record = json.load(f)
|
|
148
|
+
except (OSError, ValueError):
|
|
149
|
+
record = blank
|
|
150
|
+
if not isinstance(record, type(blank)):
|
|
151
|
+
record = blank
|
|
152
|
+
before = copy.deepcopy(record)
|
|
153
|
+
yield record
|
|
154
|
+
if record != before:
|
|
155
|
+
write_json(path, record, **dump)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def self_cmd() -> list[str]:
|
|
159
|
+
"""argv that runs this same pp (same interpreter, same tree), for detached helpers like a queue's worker: the
|
|
160
|
+
tree's bin/pp in a clone or a release; in an installed package, which has no bin/, `python -P -m pp` (-P: a
|
|
161
|
+
pp/ folder in the current directory must not stand in for the installed one)."""
|
|
162
|
+
launcher = os.path.join(pp_root(), "bin", "pp")
|
|
163
|
+
return [sys.executable, launcher] if os.path.isfile(launcher) else [sys.executable, "-P", "-m", "pp"]
|