alissa-tools-github-genloop 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.
@@ -0,0 +1,461 @@
1
+ """The plan directory as the SEAT sees it: frontmatter, never prose (D14).
2
+
3
+ After a pass the seat reads the pushed branch's `plan.md` and `tasks/*.md`
4
+ from the plans repository, parses their FRONTMATTER -- the structured fields
5
+ the plan row and the trailers need (`id`, `title`, `anchor`, `program`,
6
+ `sources`, each task's `id` and `commitClass`) -- and renders the PR body by
7
+ COPYING `plan.md`'s header lines, *Why now* and *Summary*. It never judges
8
+ the prose; the judgment was the session's, and the refutation is revloop's.
9
+
10
+ The YAML subset parser below is copied from orcloop's `plans.py` (the plan
11
+ close edge reads the same files with it): the dist carries no third-party
12
+ runtime dependency, and the files are written to a schema that needs only
13
+ `key: value` pairs, flow mappings and sequences, and block sequences of such
14
+ values. `plan-lint` has already validated the files, so a shape this parser
15
+ cannot read is a bypass, not a style.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from dataclasses import dataclass, field
22
+ from typing import Any, Sequence
23
+
24
+ # The marker the seat writes into every plan PR body, on a line of its own --
25
+ # one of revloop's four plan signals (§6.1) and orcloop's plan-PR test.
26
+ PLAN_MARKER = "<!-- alissa-genloop:plan v1 -->"
27
+ PLAN_TRAILER = "Alissa-Plan"
28
+ ANCHOR_TRAILER = "Plan-Anchor"
29
+
30
+ # Design §7.1, in severity order; a task's `commitClass` draws on the last ten.
31
+ COMMIT_CLASSES = (
32
+ "unanchored",
33
+ "schema-migration",
34
+ "auth-secrets",
35
+ "pricing-billing",
36
+ "customer-promise",
37
+ "product-surface",
38
+ "gate-verification",
39
+ "drift-fix",
40
+ "flaky-test",
41
+ "exhaust-followup",
42
+ "doc-correction",
43
+ )
44
+
45
+ PLAN_ID_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9][a-z0-9-]{2,47}$")
46
+ PLAN_BRANCH_RE = re.compile(r"^PLAN-\d{8}T\d{6}Z-[a-z0-9-]+$")
47
+ SOURCE_KEY_RE = re.compile(r"^(gate|later|minor|esc|drift|stale):\S+$")
48
+ _FRONTMATTER_RE = re.compile(r"^---\n([\s\S]*?)\n---\n?([\s\S]*)$")
49
+ _TASK_FILE_RE = re.compile(r"^[a-z0-9][a-z0-9-]*\.md$")
50
+ MAX_TASKS = 8
51
+ PR_BODY_MAX_CHARS = 60000
52
+
53
+
54
+ class PlanError(ValueError):
55
+ """The pushed plan cannot be read into a plan row; the seat holds it."""
56
+
57
+
58
+ # --- the YAML subset (copied from orcloop's plans.py) ------------------------
59
+
60
+
61
+ class FrontmatterError(ValueError):
62
+ """The frontmatter is not the YAML subset the plan files are written in."""
63
+
64
+
65
+ # --- the YAML subset ----------------------------------------------------------
66
+ #
67
+ # The dist carries no third-party runtime dependency, and the plan files are
68
+ # written to a schema (design §5.3) that needs only: `key: value` pairs; a
69
+ # value that is a plain or quoted scalar, a flow mapping `{ a: b }` or a flow
70
+ # sequence `[a, b]`; and a block sequence of such values under a key. Anything
71
+ # else -- a block mapping inside a list, an anchor, a multi-line scalar -- is
72
+ # refused (rule 2): the lint has already validated the files against the
73
+ # schema, so a shape this parser cannot read is a bypass, not a style.
74
+
75
+ _PLAIN_INT_RE = re.compile(r"^[-+]?[0-9]+$")
76
+ _PLAIN_FLOAT_RE = re.compile(r"^[-+]?(?:[0-9]+\.[0-9]*|\.[0-9]+)(?:[eE][-+]?[0-9]+)?$")
77
+
78
+
79
+ def _strip_comment(line: str) -> str:
80
+ """A line with its trailing ` # comment` removed, quotes respected."""
81
+ quote = ""
82
+ for index, char in enumerate(line):
83
+ if quote:
84
+ if char == quote:
85
+ quote = ""
86
+ elif char == "\\" and quote == '"':
87
+ continue
88
+ elif char in "\"'":
89
+ quote = char
90
+ elif char == "#" and (index == 0 or line[index - 1] in " \t"):
91
+ return line[:index].rstrip()
92
+ return line.rstrip()
93
+
94
+
95
+ class _Flow:
96
+ """A tiny recursive-descent reader for one value (flow or scalar)."""
97
+
98
+ def __init__(self, text: str):
99
+ self.text = text
100
+ self.pos = 0
101
+
102
+ def _ws(self) -> None:
103
+ while self.pos < len(self.text) and self.text[self.pos] in " \t":
104
+ self.pos += 1
105
+
106
+ def value(self, stops: str = "") -> Any:
107
+ self._ws()
108
+ if self.pos >= len(self.text):
109
+ return None
110
+ char = self.text[self.pos]
111
+ if char == "{":
112
+ return self._mapping()
113
+ if char == "[":
114
+ return self._sequence()
115
+ if char == '"':
116
+ return self._double()
117
+ if char == "'":
118
+ return self._single()
119
+ return self._plain(stops)
120
+
121
+ def _mapping(self) -> dict:
122
+ self.pos += 1
123
+ out: dict = {}
124
+ while True:
125
+ self._ws()
126
+ if self.pos < len(self.text) and self.text[self.pos] == "}":
127
+ self.pos += 1
128
+ return out
129
+ key = self.value(stops=":,}")
130
+ self._ws()
131
+ if self.pos >= len(self.text) or self.text[self.pos] != ":":
132
+ raise FrontmatterError(f"expected `:` in a flow mapping: {self.text!r}")
133
+ self.pos += 1
134
+ if not isinstance(key, str) or key in out:
135
+ raise FrontmatterError(f"bad or repeated key {key!r} in {self.text!r}")
136
+ out[key] = self.value(stops=",}")
137
+ self._ws()
138
+ if self.pos < len(self.text) and self.text[self.pos] == ",":
139
+ self.pos += 1
140
+ continue
141
+ if self.pos < len(self.text) and self.text[self.pos] == "}":
142
+ self.pos += 1
143
+ return out
144
+ raise FrontmatterError(f"unterminated flow mapping: {self.text!r}")
145
+
146
+ def _sequence(self) -> list:
147
+ self.pos += 1
148
+ out: list = []
149
+ while True:
150
+ self._ws()
151
+ if self.pos < len(self.text) and self.text[self.pos] == "]":
152
+ self.pos += 1
153
+ return out
154
+ out.append(self.value(stops=",]"))
155
+ self._ws()
156
+ if self.pos < len(self.text) and self.text[self.pos] == ",":
157
+ self.pos += 1
158
+ continue
159
+ if self.pos < len(self.text) and self.text[self.pos] == "]":
160
+ self.pos += 1
161
+ return out
162
+ raise FrontmatterError(f"unterminated flow sequence: {self.text!r}")
163
+
164
+ def _double(self) -> str:
165
+ self.pos += 1
166
+ out: list[str] = []
167
+ escapes = {'"': '"', "\\": "\\", "n": "\n", "t": "\t", "/": "/"}
168
+ while self.pos < len(self.text):
169
+ char = self.text[self.pos]
170
+ if char == "\\":
171
+ nxt = self.text[self.pos + 1:self.pos + 2]
172
+ if nxt not in escapes:
173
+ raise FrontmatterError(f"unsupported escape \\{nxt} in {self.text!r}")
174
+ out.append(escapes[nxt])
175
+ self.pos += 2
176
+ continue
177
+ if char == '"':
178
+ self.pos += 1
179
+ return "".join(out)
180
+ out.append(char)
181
+ self.pos += 1
182
+ raise FrontmatterError(f"unterminated string: {self.text!r}")
183
+
184
+ def _single(self) -> str:
185
+ self.pos += 1
186
+ out: list[str] = []
187
+ while self.pos < len(self.text):
188
+ char = self.text[self.pos]
189
+ if char == "'":
190
+ if self.text[self.pos + 1:self.pos + 2] == "'":
191
+ out.append("'")
192
+ self.pos += 2
193
+ continue
194
+ self.pos += 1
195
+ return "".join(out)
196
+ out.append(char)
197
+ self.pos += 1
198
+ raise FrontmatterError(f"unterminated string: {self.text!r}")
199
+
200
+ def _plain(self, stops: str) -> Any:
201
+ start = self.pos
202
+ while self.pos < len(self.text):
203
+ char = self.text[self.pos]
204
+ if char in stops and (char != ":" or self.text[self.pos + 1:self.pos + 2] in ("", " ", "\t")):
205
+ break
206
+ self.pos += 1
207
+ token = self.text[start:self.pos].strip()
208
+ if token.startswith(("&", "*", "!", "|", ">", "%", "@", "`")):
209
+ raise FrontmatterError(f"unsupported YAML construct {token!r}")
210
+ if token in ("", "~", "null", "Null", "NULL"):
211
+ return None
212
+ if token in ("true", "True", "TRUE"):
213
+ return True
214
+ if token in ("false", "False", "FALSE"):
215
+ return False
216
+ if _PLAIN_INT_RE.match(token):
217
+ return int(token)
218
+ if _PLAIN_FLOAT_RE.match(token):
219
+ return float(token)
220
+ return token
221
+
222
+ def done(self) -> bool:
223
+ self._ws()
224
+ return self.pos >= len(self.text)
225
+
226
+
227
+ def _one_value(text: str) -> Any:
228
+ reader = _Flow(text)
229
+ value = reader.value()
230
+ if not reader.done():
231
+ raise FrontmatterError(f"trailing text after a value: {text!r}")
232
+ return value
233
+
234
+
235
+ def parse_frontmatter_yaml(text: str) -> dict:
236
+ """The frontmatter subset (see above) as a dict, key order kept. Raises
237
+ `FrontmatterError` on anything outside it."""
238
+ out: dict = {}
239
+ current_list: list | None = None
240
+ for raw in text.split("\n"):
241
+ line = _strip_comment(raw)
242
+ if not line.strip():
243
+ continue
244
+ if line[0] in " \t" or line.startswith("-"):
245
+ stripped = line.strip()
246
+ if current_list is None or not stripped.startswith("- ") and stripped != "-":
247
+ raise FrontmatterError(f"unexpected indented line {raw!r}")
248
+ item = stripped[1:].strip()
249
+ if re.match(r"^[^\s\"'{\[][^:]*:(\s|$)", item):
250
+ raise FrontmatterError(f"a block mapping inside a list is not supported: {raw!r}")
251
+ current_list.append(_one_value(item))
252
+ continue
253
+ key, sep, rest = line.partition(":")
254
+ key = key.strip()
255
+ if not sep or not key or key in out or not re.match(r"^[A-Za-z_][A-Za-z0-9_-]*$", key):
256
+ raise FrontmatterError(f"not a `key: value` line: {raw!r}")
257
+ if rest.strip():
258
+ out[key] = _one_value(rest.strip())
259
+ current_list = None
260
+ else:
261
+ current_list = []
262
+ out[key] = current_list
263
+ return out
264
+
265
+
266
+ # --- the plan, as the seat reads it ------------------------------------------
267
+
268
+
269
+ @dataclass(frozen=True)
270
+ class PlanTask:
271
+ id: str
272
+ commit_class: str
273
+
274
+
275
+ @dataclass(frozen=True)
276
+ class Plan:
277
+ plan_id: str
278
+ title: str
279
+ anchor: dict
280
+ program: str
281
+ sources: tuple[dict, ...]
282
+ tasks: tuple[PlanTask, ...]
283
+ body: str = field(repr=False)
284
+ frontmatter: dict = field(repr=False, default_factory=dict)
285
+
286
+ @property
287
+ def plan_class(self) -> str:
288
+ """The most severe `commitClass` of the tasks (§7.1's order)."""
289
+ ranks = [COMMIT_CLASSES.index(t.commit_class) for t in self.tasks if t.commit_class in COMMIT_CLASSES]
290
+ return COMMIT_CLASSES[min(ranks)] if ranks else "unanchored"
291
+
292
+ @property
293
+ def source_keys(self) -> list[str]:
294
+ return [str(s["key"]) for s in self.sources]
295
+
296
+ @property
297
+ def anchor_ref(self) -> str:
298
+ """`<kind>:<ref>` for the `Plan-Anchor:` trailer and the rule pattern."""
299
+ return anchor_ref(self.anchor)
300
+
301
+ def loop_plan_record(self, *, repo: str, pr_number: int, status: str, at_ms: int | None = None) -> dict:
302
+ """The `POST /v1/loop/plans` body (Studio G3-2)."""
303
+ record: dict = {
304
+ "repo": repo,
305
+ "prNumber": int(pr_number),
306
+ "planId": self.plan_id,
307
+ "title": self.title[:300],
308
+ "anchor": dict(self.anchor),
309
+ "class": self.plan_class,
310
+ "status": status,
311
+ "sources": [{"key": str(s["key"]), "evidence": str(s.get("evidence") or "")[:2000]} for s in self.sources],
312
+ "tasks": [{"id": t.id} for t in self.tasks],
313
+ }
314
+ if at_ms is not None:
315
+ record["at"] = int(at_ms)
316
+ return record
317
+
318
+
319
+ def anchor_ref(anchor: dict) -> str:
320
+ kind = str(anchor.get("kind") or "")
321
+ if kind == "milestone":
322
+ return f"milestone:{anchor.get('milestoneId')}"
323
+ if kind == "criterion":
324
+ return f"criterion:{anchor.get('milestoneId')}/{anchor.get('criterionId')}"
325
+ if kind == "objective":
326
+ return f"objective:{anchor.get('objectiveId')}"
327
+ if kind == "exhaust":
328
+ return f"exhaust:{anchor.get('class')}"
329
+ return f"{kind or 'unknown'}:"
330
+
331
+
332
+ def anchor_rule_pattern(anchor: dict) -> str | None:
333
+ """The `proposalRules` pattern a rejection writes for the anchor (§4.4):
334
+ `milestone:<id>` or `objective:<id>`; an exhaust anchor's keys are its
335
+ sources, which get their own rows."""
336
+ kind = anchor.get("kind")
337
+ if kind in ("milestone", "criterion") and anchor.get("milestoneId"):
338
+ return f"milestone:{anchor['milestoneId']}"
339
+ if kind == "objective" and anchor.get("objectiveId"):
340
+ return f"objective:{anchor['objectiveId']}"
341
+ return None
342
+
343
+
344
+ def split_frontmatter(path: str, text: str) -> tuple[dict, str]:
345
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
346
+ match = _FRONTMATTER_RE.match(text)
347
+ if match is None:
348
+ raise PlanError(f"{path} has no YAML frontmatter")
349
+ try:
350
+ return parse_frontmatter_yaml(match.group(1)), match.group(2)
351
+ except FrontmatterError as exc:
352
+ raise PlanError(f"{path}: {exc}") from None
353
+
354
+
355
+ def task_file_names(names: Sequence[str]) -> list[str]:
356
+ return sorted(name for name in names if _TASK_FILE_RE.match(name))
357
+
358
+
359
+ def parse_plan(plan_md: str, task_files: dict[str, str], *, expect_id: str | None = None) -> Plan:
360
+ """The plan row's facts from `plan.md` and `{file name: text}` of the
361
+ task files. Raises `PlanError` naming the first thing it cannot read."""
362
+ front, body = split_frontmatter("plan.md", plan_md)
363
+ plan_id = front.get("id")
364
+ if not isinstance(plan_id, str) or not PLAN_ID_RE.match(plan_id):
365
+ raise PlanError(f"plan.md id {plan_id!r} is not <yyyy-mm-dd>-<slug>")
366
+ if expect_id is not None and plan_id != expect_id:
367
+ raise PlanError(f"plan.md id {plan_id!r} is not the recorded planId {expect_id!r}")
368
+ title = front.get("title")
369
+ if not isinstance(title, str) or not title.strip():
370
+ raise PlanError("plan.md has no title")
371
+ anchor = front.get("anchor")
372
+ if not isinstance(anchor, dict) or anchor.get("kind") not in ("milestone", "criterion", "objective", "exhaust"):
373
+ raise PlanError(f"plan.md anchor {anchor!r} is not one of milestone, criterion, objective, exhaust")
374
+ program = front.get("program")
375
+ if not isinstance(program, str) or not program:
376
+ raise PlanError("plan.md has no program")
377
+ sources: list[dict] = []
378
+ for entry in front.get("sources") or []:
379
+ if not isinstance(entry, dict) or not isinstance(entry.get("key"), str) \
380
+ or not SOURCE_KEY_RE.match(entry["key"]):
381
+ raise PlanError(f"plan.md source {entry!r} is not {{key, evidence}} with a source key")
382
+ sources.append(entry)
383
+ names = task_file_names(list(task_files))
384
+ if not 1 <= len(names) <= MAX_TASKS:
385
+ raise PlanError(f"the plan has {len(names)} task files (1–{MAX_TASKS})")
386
+ tasks: list[PlanTask] = []
387
+ for name in names:
388
+ task_front, _ = split_frontmatter(f"tasks/{name}", task_files[name])
389
+ task_id = task_front.get("id")
390
+ if not isinstance(task_id, str) or f"{task_id}.md" != name:
391
+ raise PlanError(f"tasks/{name}: id {task_id!r} does not match the file name")
392
+ klass = task_front.get("commitClass")
393
+ if klass not in COMMIT_CLASSES[1:]:
394
+ raise PlanError(f"tasks/{name}: commitClass {klass!r} is not one of §7.1's task classes")
395
+ tasks.append(PlanTask(id=task_id, commit_class=str(klass)))
396
+ return Plan(
397
+ plan_id=plan_id, title=title.strip(), anchor=anchor, program=program, sources=tuple(sources),
398
+ tasks=tuple(tasks), body=body, frontmatter=front,
399
+ )
400
+
401
+
402
+ # --- the PR body ---------------------------------------------------------------
403
+
404
+
405
+ def _section(body: str, heading: str) -> str:
406
+ """The text under `## <heading>` up to the next `## ` heading, or ""."""
407
+ lines = body.split("\n")
408
+ out: list[str] = []
409
+ inside = False
410
+ for line in lines:
411
+ if line.startswith("## "):
412
+ if inside:
413
+ break
414
+ inside = line[3:].strip() == heading
415
+ continue
416
+ if inside:
417
+ out.append(line)
418
+ return "\n".join(out).strip()
419
+
420
+
421
+ def render_pr_title(plan: Plan) -> str:
422
+ return f"Plan: {plan.title}"[:256]
423
+
424
+
425
+ def render_pr_body(plan: Plan, *, branch: str, plans_dir: str, budget_note: str = "") -> str:
426
+ """The plan PR's body: the header lines, *Why now* and *Summary* COPIED
427
+ from `plan.md`, the marker on a line of its own, and the two trailers as
428
+ the body's last lines. The files are the truth; this is a rendering."""
429
+ budget = plan.frontmatter.get("budget") if isinstance(plan.frontmatter.get("budget"), dict) else {}
430
+ header = [
431
+ f"# Plan: {plan.title}",
432
+ PLAN_MARKER,
433
+ "",
434
+ f"- **Directory:** `{plans_dir}/` on `{branch}`",
435
+ f"- **Anchor:** `{plan.anchor_ref}` · **Program:** `{plan.program}`"
436
+ + (f" · **Milestone:** `{plan.frontmatter.get('milestone')}`" if plan.frontmatter.get("milestone") else ""),
437
+ f"- **Class:** `{plan.plan_class}` · **Tasks:** {len(plan.tasks)}",
438
+ ]
439
+ if budget:
440
+ header.append(f"- **Budget estimate:** ${budget.get('estimateUsd')} — {budget.get('basis')}")
441
+ if plan.source_keys:
442
+ header.append("- **Sources:** " + ", ".join(f"`{k}`" for k in plan.source_keys))
443
+ if budget_note:
444
+ header.append(f"- **Budget:** {budget_note}")
445
+ parts = ["\n".join(header)]
446
+ why_now = _section(plan.body, "Why now")
447
+ summary = _section(plan.body, "Summary")
448
+ if why_now:
449
+ parts.append("## Why now\n\n" + why_now)
450
+ if summary:
451
+ parts.append("## Summary\n\n" + summary)
452
+ parts.append(
453
+ "_Opened by the genloop seat. The plan directory is the truth; this body is a rendering. "
454
+ "Review it under the plan directive; amendments come from the next genloop pass._"
455
+ )
456
+ trailers = f"{PLAN_TRAILER}: {plan.plan_id}\n{ANCHOR_TRAILER}: {plan.anchor_ref}"
457
+ body = "\n\n".join(parts)
458
+ keep = PR_BODY_MAX_CHARS - len(trailers) - 40
459
+ if len(body) > keep:
460
+ body = body[:keep].rstrip() + "\n\n[… truncated — read plan.md]"
461
+ return body + "\n\n" + trailers + "\n"
@@ -0,0 +1,61 @@
1
+ """Subprocess helpers, copied from orcloop's `proc.py`.
2
+
3
+ `github` shells out to `gh` (which keeps its own auth handling) and
4
+ `alissa_cli` to `alissa`. The Alissa REST client does NOT go through here: it
5
+ is in-process `urllib`, never a subprocess. Nothing in this module ever
6
+ touches a token.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import subprocess
15
+ from typing import Sequence
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+
20
+ class CommandError(RuntimeError):
21
+ def __init__(self, argv: Sequence[str], returncode: int, stderr: str):
22
+ self.argv = list(argv)
23
+ self.returncode = returncode
24
+ self.stderr = stderr
25
+ super().__init__(f"{argv[0]} exited {returncode}: {stderr.strip()[:400]}")
26
+
27
+
28
+ def run(
29
+ argv: Sequence[str],
30
+ *,
31
+ timeout: int = 60,
32
+ check: bool = True,
33
+ cwd: "str | os.PathLike[str] | None" = None,
34
+ ) -> str:
35
+ """Run a command, return stdout. Never uses shell=True."""
36
+ log.debug("exec: %s", " ".join(argv))
37
+ try:
38
+ proc = subprocess.run(
39
+ list(argv),
40
+ capture_output=True,
41
+ text=True,
42
+ timeout=timeout,
43
+ cwd=str(cwd) if cwd is not None else None,
44
+ )
45
+ except subprocess.TimeoutExpired as exc:
46
+ raise CommandError(argv, -1, f"timed out after {timeout}s") from exc
47
+
48
+ if check and proc.returncode != 0:
49
+ raise CommandError(argv, proc.returncode, proc.stderr)
50
+ return proc.stdout
51
+
52
+
53
+ def run_json(argv: Sequence[str], *, timeout: int = 60):
54
+ """Run a command whose stdout is JSON."""
55
+ out = run(argv, timeout=timeout).strip()
56
+ if not out:
57
+ return None
58
+ try:
59
+ return json.loads(out)
60
+ except json.JSONDecodeError as exc:
61
+ raise CommandError(argv, 0, f"expected JSON, got: {out[:300]}") from exc
@@ -0,0 +1,113 @@
1
+ """The seat's own state: a small JSON file under the state dir.
2
+
3
+ L8 (external truth): the spent-set, the plans and the rules live in Studio and
4
+ GitHub and are re-derived every pass. What is left for this file is what only
5
+ the seat knows -- the pass clock, the stamps it spawned, which records it has
6
+ ingested, which PR a stamp opened, and the once-only marks on pages and
7
+ writes. Every row carries the time it was written and ages out after
8
+ `LEDGER_MAX_AGE_SECONDS`, like every other seat's ledger.
9
+
10
+ Writes are atomic (temp file + rename), so a crash mid-write leaves the
11
+ previous state whole.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import os
19
+ import tempfile
20
+ from pathlib import Path
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+ STATE_FILENAME = "genloop-state.json"
25
+ LEDGER_MAX_AGE_SECONDS = 30 * 86400
26
+
27
+
28
+ def write_json_atomic(path: Path, payload: object) -> None:
29
+ path.parent.mkdir(parents=True, exist_ok=True)
30
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
31
+ try:
32
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
33
+ json.dump(payload, handle, indent=2, sort_keys=True)
34
+ handle.write("\n")
35
+ os.replace(tmp, path)
36
+ except BaseException:
37
+ try:
38
+ os.unlink(tmp)
39
+ except OSError:
40
+ pass
41
+ raise
42
+
43
+
44
+ class State:
45
+ """`meta` (latest-wins strings) and `ledger` (key -> written-at epoch)."""
46
+
47
+ def __init__(self, path: Path):
48
+ self.path = path
49
+ self.meta: dict[str, str] = {}
50
+ self.ledger: dict[str, int] = {}
51
+ self._load()
52
+
53
+ @classmethod
54
+ def in_dir(cls, state_dir: Path) -> "State":
55
+ return cls(state_dir / STATE_FILENAME)
56
+
57
+ def _load(self) -> None:
58
+ try:
59
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
60
+ except FileNotFoundError:
61
+ return
62
+ except (OSError, json.JSONDecodeError) as exc:
63
+ # A corrupt file must not wedge the seat; it re-derives what it can.
64
+ log.error("state: %s is unreadable (%s) — starting from an empty ledger", self.path, exc)
65
+ return
66
+ if isinstance(raw, dict):
67
+ meta = raw.get("meta")
68
+ ledger = raw.get("ledger")
69
+ if isinstance(meta, dict):
70
+ self.meta = {str(k): str(v) for k, v in meta.items()}
71
+ if isinstance(ledger, dict):
72
+ self.ledger = {str(k): int(v) for k, v in ledger.items() if isinstance(v, (int, float))}
73
+
74
+ def save(self) -> None:
75
+ write_json_atomic(self.path, {"meta": self.meta, "ledger": self.ledger})
76
+
77
+ # -- meta ----------------------------------------------------------------
78
+
79
+ def get_meta(self, key: str) -> str | None:
80
+ return self.meta.get(key)
81
+
82
+ def set_meta(self, key: str, value: str) -> None:
83
+ self.meta[key] = value
84
+ self.save()
85
+
86
+ def del_meta(self, key: str) -> None:
87
+ if self.meta.pop(key, None) is not None:
88
+ self.save()
89
+
90
+ # -- ledger --------------------------------------------------------------
91
+
92
+ def has(self, key: str) -> bool:
93
+ return key in self.ledger
94
+
95
+ def mark(self, key: str, *, at: int) -> None:
96
+ self.ledger[key] = int(at)
97
+ self.save()
98
+
99
+ def unmark(self, key: str) -> None:
100
+ if self.ledger.pop(key, None) is not None:
101
+ self.save()
102
+
103
+ def keys_with_prefix(self, prefix: str) -> list[str]:
104
+ return sorted(key for key in self.ledger if key.startswith(prefix))
105
+
106
+ def age_out(self, *, now: int) -> int:
107
+ """Drop ledger rows older than the max age; returns how many."""
108
+ stale = [key for key, at in self.ledger.items() if now - at > LEDGER_MAX_AGE_SECONDS]
109
+ for key in stale:
110
+ del self.ledger[key]
111
+ if stale:
112
+ self.save()
113
+ return len(stale)
@@ -0,0 +1 @@
1
+ 0.1.0
@@ -0,0 +1,46 @@
1
+ import os
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass(frozen=True, slots=True)
6
+ class Version:
7
+ name: str
8
+ value: str
9
+
10
+ def components(self, as_int: bool = False) -> list:
11
+ return [int(val) if as_int else val for val in self.value.split(".")]
12
+
13
+ @property
14
+ def major(self) -> int:
15
+ component, *_ = self.components(as_int=True)
16
+ return component
17
+
18
+ @property
19
+ def minor(self) -> int:
20
+ _, component, *_ = self.components(as_int=True)
21
+ return component
22
+
23
+ @property
24
+ def patch(self) -> int:
25
+ *_, component = self.components(as_int=True)
26
+ return component
27
+
28
+ @classmethod
29
+ def from_path(cls, dirpath: str, name: str):
30
+ for file in os.listdir(dirpath):
31
+ if file.lower().endswith("version"):
32
+ filepath = os.path.join(dirpath, file)
33
+ break
34
+ else:
35
+ raise ValueError("Version file not found for package name: " + name)
36
+
37
+ with open(filepath, "r") as version_file:
38
+ version_value = version_file.readline().strip()
39
+ return cls(name=name, value=version_value)
40
+
41
+
42
+ try:
43
+ version = Version.from_path(name="alissa-tools-github-genloop", dirpath=os.path.dirname(__file__))
44
+ except Exception:
45
+ print("Version file not found for package name: alissa-tools-github-genloop, using 0.0.0")
46
+ version = Version(name="alissa-tools-github-genloop", value="0.0.0")