fileplan 0.5.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.
fileplan/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """fileplan: a plan.toml interpreter.
2
+
3
+ A directory is a state, and a transition moves an item from one state to
4
+ another. Both are declared in plan.toml rather than written in code.
5
+
6
+ The vocabulary is documented in docs/method.md. The document ships in the
7
+ project's own repository rather than in this package.
8
+ """
fileplan/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Support `python -m fileplan`."""
2
+
3
+ import sys
4
+
5
+ from fileplan.cli import main
6
+
7
+ sys.exit(main())
fileplan/claim.py ADDED
@@ -0,0 +1,343 @@
1
+ """Claims: which session holds an item, and whether that session is still there.
2
+
3
+ A claim is a state change that lasts a session, and a second session can see
4
+ it. A claim is a file, and the file outlives the process that wrote it. The
5
+ run lock is the opposite, because the kernel releases the lock when the
6
+ process goes.
7
+
8
+ Nothing reclaims a claim automatically, and nothing tries to. A claim probed as dead
9
+ is named in the listing and waits for a person. So does a claim taken on a
10
+ host this one cannot reach.
11
+
12
+ Who a session is comes from plan.toml's `[identity]`, an ordered list of
13
+ environment variable names. The first name that is set wins. `identity` is the
14
+ only place in the tool that reads the environment. The identity is a host and
15
+ a pid, with no session id, so a reused pid can make a dead session's claim
16
+ look live.
17
+
18
+ Pure but for `read`, `write`, `remove` and `identity`.
19
+
20
+ See docs/method.md#the-claim
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import datetime as dt
26
+ import os
27
+ import socket
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+ from typing import Any, Mapping
31
+
32
+ from fileplan import item
33
+ from fileplan.declaration import CLAIM_KEYS, Declaration, Refusal, collecting
34
+ from fileplan.lock import LOCAL_DIR
35
+
36
+ #: The capability a state opts into. The seam that takes, holds and drops a
37
+ #: claim is `fileplan.transition`, because it mutates under the lock and this
38
+ #: module deliberately takes none. See docs/method.md#the-claimed-state
39
+ NAME = "claimed"
40
+
41
+ #: Under `local/`, beside the lock: session-local state, gitignored.
42
+ CLAIMS_DIR = "claims"
43
+
44
+ #: Plain `.toml`, unfenced: a `+++` line is not valid TOML, and a record a
45
+ #: person cats should be something they can parse.
46
+ SUFFIX = ".toml"
47
+
48
+ #: What a record holds, in canonical order. The item's slug is not here: the
49
+ #: record's location answers that already, and a second answer could disagree.
50
+ FIELDS = ("host", "pid", "taken")
51
+
52
+ #: The four outcomes of the probe. `ELSEWHERE` and `UNKNOWN` are not "maybe
53
+ #: dead" but the two ways the tool cannot know, and they end where `DEAD`
54
+ #: ends: named, never auto-released.
55
+ ALIVE = "alive"
56
+ DEAD = "dead"
57
+ ELSEWHERE = "elsewhere"
58
+ UNKNOWN = "unknown"
59
+
60
+ #: The four as a closed set: what a row's status field may hold, and what a
61
+ #: filter over the key grades against, so `--has claim-status=zzz` refuses by
62
+ #: name. Ordered alive-first, the order a person reads them in.
63
+ STATUSES = (ALIVE, DEAD, ELSEWHERE, UNKNOWN)
64
+
65
+ #: The two fields a claim gives a row, unpacked so nothing below spells one
66
+ #: as a literal. Their one home is `fileplan.declaration.CLAIM_KEYS`, where a
67
+ #: redeclaration of either is refused.
68
+ BY, STATUS = CLAIM_KEYS
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Identity:
73
+ """Who a session is: the machine, and the process on it.
74
+
75
+ `pid` is `None` when no declared variable is set — a person at a shell,
76
+ say. That is a usable identity rather than an error: it owns its own
77
+ claims on its own host, and the probe reports `UNKNOWN` for them.
78
+ """
79
+
80
+ host: str
81
+ pid: int | None = None
82
+
83
+
84
+ def identity(declaration: Declaration) -> Identity:
85
+ """This session's identity, per `[identity]`, or raise `Refusal`.
86
+
87
+ The first declared name that is set supplies the pid, and one set to
88
+ something that is not a positive whole number refuses by name rather than
89
+ falling through: a silent fallback would record an identity the operator
90
+ did not choose and cannot see.
91
+ """
92
+ host = socket.gethostname()
93
+ for name in declaration.pid_names:
94
+ value = os.environ.get(name)
95
+ if value is None:
96
+ continue
97
+ return Identity(host=host, pid=_pid(name, value))
98
+ return Identity(host=host)
99
+
100
+
101
+ def _pid(name: str, value: str) -> int:
102
+ if value.isascii() and value.isdigit() and int(value) > 0:
103
+ return int(value)
104
+ raise Refusal(
105
+ f'{name} is set to "{value}", which is not a pid. A session\'s pid is '
106
+ "a positive whole number, and it is what the liveness probe asks the "
107
+ "kernel about — text recorded in its place would probe nothing"
108
+ )
109
+
110
+
111
+ def path(root: Path, slug: str) -> Path:
112
+ """Where `slug`'s claim lives: `local/claims/<slug>.toml` under `root`."""
113
+ return root / LOCAL_DIR / CLAIMS_DIR / f"{slug}{SUFFIX}"
114
+
115
+
116
+ def record(identity: Identity, *, taken: dt.datetime) -> dict[str, Any]:
117
+ """The flat mapping a claim is written as. Pure.
118
+
119
+ `taken` must be aware: a record is read on machines other than the one
120
+ that wrote it, and a naive stamp means whatever the reader's clock means.
121
+ """
122
+ if taken.tzinfo is None or taken.tzinfo.utcoffset(taken) is None:
123
+ raise Refusal(
124
+ "a claim is taken at an aware datetime, not a naive one. The "
125
+ "record is read on other machines — this tree is shared — and a "
126
+ "stamp with no offset means a different moment on each of them"
127
+ )
128
+ written: dict[str, Any] = {"host": identity.host}
129
+ if identity.pid is not None:
130
+ written["pid"] = identity.pid
131
+ written["taken"] = taken
132
+ return written
133
+
134
+
135
+ def owns(record: Mapping[str, Any], identity: Identity) -> bool:
136
+ """Whether `identity` is the session that holds `record`.
137
+
138
+ A record carrying no pid is owned by any identity on its host, which lets
139
+ a person at a shell complete a claim they took at that same shell. This is
140
+ the question; the executor is the refusal.
141
+ """
142
+ if record.get("host") != identity.host:
143
+ return False
144
+ pid = record.get("pid")
145
+ return pid is None or pid == identity.pid
146
+
147
+
148
+ def alive(record: Mapping[str, Any], identity: Identity) -> str:
149
+ """One of `ALIVE`, `DEAD`, `ELSEWHERE`, `UNKNOWN`.
150
+
151
+ Host-gated, and the gate is load-bearing: `os.kill(pid, 0)` asks this
152
+ kernel about its own process table, so on a record from another machine it
153
+ would answer about whatever local process wears that number. It is never
154
+ called for another host. `PermissionError` is `ALIVE` — the process
155
+ exists, it is simply not ours to signal.
156
+ """
157
+ if record.get("host") != identity.host:
158
+ return ELSEWHERE
159
+
160
+ pid = record.get("pid")
161
+ if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
162
+ return UNKNOWN
163
+
164
+ try:
165
+ os.kill(pid, 0)
166
+ except ProcessLookupError:
167
+ return DEAD
168
+ except PermissionError:
169
+ return ALIVE
170
+ return ALIVE
171
+
172
+
173
+ #: What each of the probe's four answers says about a holder, in the phrase a
174
+ #: refusal carries. `UNKNOWN` and `ELSEWHERE` say what cannot be known rather
175
+ #: than guessing at "maybe dead".
176
+ SAID = {
177
+ ALIVE: "running",
178
+ DEAD: "not running",
179
+ ELSEWHERE: "another machine, which this one cannot probe",
180
+ UNKNOWN: "no pid on the record, so there is nothing to probe",
181
+ }
182
+
183
+
184
+ def _holder(record: Mapping[str, Any]) -> str:
185
+ """Who holds a claim: `a-host.local pid 4213`, or `a-host.local` alone.
186
+
187
+ The one home for a holder's name, so the listing and the refusals cannot
188
+ name one session two ways. Best effort rather than a grader: a
189
+ hand-edited record missing a field still names something a person can act
190
+ on. `errors` is the grader, and `holders` runs it.
191
+ """
192
+ who = str(record.get("host", "an unnamed host"))
193
+ pid = record.get("pid")
194
+ return f"{who} pid {pid}" if pid is not None else who
195
+
196
+
197
+ def describe(record: Mapping[str, Any], status: str) -> str:
198
+ """A holder, as a phrase: `host pid 4213 (not running), taken <when>`.
199
+
200
+ The English of a refusal, built on `_holder` so the sentence and the row
201
+ agree about who. A row carries values rather than sentences, which is why
202
+ the listing takes `fields` instead.
203
+ """
204
+ return f"{_holder(record)} ({SAID.get(status, status)}), taken {_when(record)}"
205
+
206
+
207
+ def fields(record: Mapping[str, Any], status: str) -> dict[str, str]:
208
+ """The two values a claim gives a row: its holder, and the probe's word.
209
+
210
+ Pure, and deliberately not a sentence: `claim-status` holds one of
211
+ `STATUSES`, so it filters like any declared key with `values` and
212
+ `--has claim-status=dead` is "what needs releasing".
213
+ """
214
+ return {BY: _holder(record), STATUS: status}
215
+
216
+
217
+ def _when(record: Mapping[str, Any]) -> str:
218
+ """When the claim was taken, spelled the way the record on disk spells it.
219
+
220
+ `str(datetime)` separates the date and the time with a space where TOML
221
+ writes a `T`, and the phrase should agree with the file.
222
+ """
223
+ taken = record.get("taken")
224
+ if isinstance(taken, dt.datetime):
225
+ return taken.isoformat()
226
+ return "at no recorded time" if taken is None else str(taken)
227
+
228
+
229
+ def errors(record: Mapping[str, Any]) -> list[str]:
230
+ """Every way `record` is not a claim record. Pure, and collected at once.
231
+
232
+ `fileplan.declaration.shape_errors`' shape: one pass, every defect named,
233
+ so a hand-edited record is fixed in one sitting.
234
+ """
235
+ found = [
236
+ f'"{name}" is not a claim record field (a record holds: '
237
+ f"{', '.join(FIELDS)})"
238
+ for name in record
239
+ if name not in FIELDS
240
+ ]
241
+
242
+ if "host" not in record:
243
+ found.append(
244
+ "a claim record has no host, and without one its pid means "
245
+ "nothing — this tree is shared, and a pid is only a pid on the "
246
+ "machine that wrote it"
247
+ )
248
+ elif not isinstance(record["host"], str):
249
+ found.append(f"host holds {record['host']!r}, which is not a host name")
250
+
251
+ pid = record.get("pid")
252
+ if pid is not None and (
253
+ not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0
254
+ ):
255
+ found.append(
256
+ f"pid holds {pid!r}, which is not a pid. It is written as a bare "
257
+ "positive integer, because it is what the liveness probe asks "
258
+ "the kernel about"
259
+ )
260
+ return found
261
+
262
+
263
+ # --------------------------------------------------------------------------
264
+ # The filesystem: the three edges
265
+ # --------------------------------------------------------------------------
266
+
267
+
268
+ def read(path: str | os.PathLike[str]) -> dict[str, Any]:
269
+ """One claim record, parsed, or raise `Refusal`. Not graded — that is
270
+ `errors`, which is pure and reports every defect at once."""
271
+ path = Path(path)
272
+ try:
273
+ text = path.read_text(encoding="utf-8")
274
+ except UnicodeDecodeError:
275
+ raise Refusal(f"{path} is not UTF-8, so it is not a claim record") from None
276
+ except OSError as exc:
277
+ raise Refusal(f"{path} could not be read: {exc.strerror}") from None
278
+
279
+ try:
280
+ return item.flat(text, noun="claim record")
281
+ except Refusal as refusal:
282
+ raise Refusal([_not_usable(path), *refusal.messages]) from None
283
+
284
+
285
+ def _not_usable(path: Path) -> str:
286
+ """`fileplan.item.read`'s wording over the other kind of file: the line a
287
+ defect is reported under, so one bad record names itself once."""
288
+ return f"{path} is not a usable claim record"
289
+
290
+
291
+ def write(path: str | os.PathLike[str], record: Mapping[str, Any]) -> None:
292
+ """Put `record` in place of `path`, atomically, making `local/claims/`.
293
+
294
+ `fileplan.item.replace` does the writing, so a second session sees either
295
+ the whole old record or the whole new one. A record an operator has
296
+ chmodded keeps its mode; a new one lands at `0600`, the temp file's.
297
+ """
298
+ path = Path(path)
299
+ path.parent.mkdir(parents=True, exist_ok=True)
300
+ item.replace(path, item.table(record, noun="claim record"))
301
+
302
+
303
+ def holders(declaration: Declaration) -> dict[str, dict[str, str]]:
304
+ """Every claim in the tree, as slug → `fields`, or raise `Refusal`.
305
+
306
+ The edge the listing reads a claim through: one glob of
307
+ `local/claims/*.toml`, the slug taken from each stem, and each record
308
+ graded and probed once. Whether a claim belongs to an item is
309
+ `fileplan.read.listing`'s question, not this one's.
310
+
311
+ Every defect is collected and raised at once. A malformed record refuses
312
+ the listing, which is the deliberate asymmetry with a missing one: a fresh
313
+ clone must read as unclaimed, where a record that cannot be read says
314
+ something the tool does not understand about who holds an item. See
315
+ docs/method.md#the-claimed-state
316
+ """
317
+ directory = Path(declaration.root) / LOCAL_DIR / CLAIMS_DIR
318
+ who = identity(declaration)
319
+ found: dict[str, dict[str, str]] = {}
320
+ defects: list[str] = []
321
+ paths = sorted(directory.glob(f"*{SUFFIX}"), key=lambda one: one.stem)
322
+ for path, held in collecting(read, paths, defects):
323
+ if problems := errors(held):
324
+ defects += [_not_usable(path), *problems]
325
+ continue
326
+ found[path.stem] = fields(held, alive(held, who))
327
+ if defects:
328
+ raise Refusal(defects)
329
+ return found
330
+
331
+
332
+ def remove(path: str | os.PathLike[str]) -> None:
333
+ """Free the claim at `path`. The other edge, beside `read`.
334
+
335
+ A record that is not there is already gone: a claim dropped twice leaves
336
+ the same tree either way, and a missing record means unclaimed everywhere
337
+ else too.
338
+ """
339
+ path = Path(path)
340
+ try:
341
+ path.unlink(missing_ok=True)
342
+ except OSError as exc:
343
+ raise Refusal(f"{path} could not be removed: {exc.strerror}") from None