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 +8 -0
- fileplan/__main__.py +7 -0
- fileplan/claim.py +343 -0
- fileplan/cli.py +1568 -0
- fileplan/declaration.py +2039 -0
- fileplan/depends.py +166 -0
- fileplan/item.py +395 -0
- fileplan/lock.py +82 -0
- fileplan/numbered.py +263 -0
- fileplan/py.typed +0 -0
- fileplan/queued.py +178 -0
- fileplan/read.py +838 -0
- fileplan/render.py +114 -0
- fileplan/scaffold.py +134 -0
- fileplan/skills/do-next/SKILL.md +175 -0
- fileplan/skills/fileplan/SKILL.md +106 -0
- fileplan/stale.py +154 -0
- fileplan/starter/docs/archive.md +3 -0
- fileplan/starter/docs/method.md +67 -0
- fileplan/starter/docs/procedures.md +25 -0
- fileplan/starter/plan.toml +88 -0
- fileplan/subphase.py +931 -0
- fileplan/transition.py +1260 -0
- fileplan-0.5.0.dist-info/METADATA +301 -0
- fileplan-0.5.0.dist-info/RECORD +28 -0
- fileplan-0.5.0.dist-info/WHEEL +4 -0
- fileplan-0.5.0.dist-info/entry_points.txt +3 -0
- fileplan-0.5.0.dist-info/licenses/LICENSE +28 -0
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
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
|