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/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"]