pixie-lab-dibs 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.
dibs/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ # SPDX-FileCopyrightText: Simon A. F. Lund <os@safl.dk>
2
+ #
3
+ # SPDX-License-Identifier: BSD-3-Clause
4
+
5
+ """dibs: call dibs on shared lab machines.
6
+
7
+ People, ai-agents and automation-runners share lab machines. dibs keeps
8
+ a list of the things they share, fed from sources such as a config file
9
+ or a pixie server, and lets each of them see who is using a thing,
10
+ claim it, and release it. A claim is a note, not a lock: it tells
11
+ everyone who is on a machine, and it never stops anyone from logging
12
+ in.
13
+ """
14
+
15
+ __version__ = "0.1.0"
dibs/_sources.py ADDED
@@ -0,0 +1,153 @@
1
+ # SPDX-FileCopyrightText: Simon A. F. Lund <os@safl.dk>
2
+ #
3
+ # SPDX-License-Identifier: BSD-3-Clause
4
+
5
+ """Where things come from: the config file, and pixie servers.
6
+
7
+ The config file (``/etc/dibs/dibs.toml`` by default) lists things by
8
+ hand and names the pixie servers to poll::
9
+
10
+ sync_interval = 60 # seconds between syncs
11
+
12
+ [[pixie]]
13
+ url = "http://pixie:8080" # every machine pixie knows becomes a thing
14
+
15
+ [[thing]]
16
+ name = "ryzen-01" # a machine pixie does not boot
17
+ kind = "machine" # default
18
+ mac = "aa:bb:cc:dd:ee:ff" # any other key is kept as an attribute
19
+
20
+ A ``[[thing]]`` with the name of a pixie machine adds its attributes to
21
+ that machine rather than making a second thing.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import tomllib
28
+ from dataclasses import dataclass, field
29
+ from datetime import UTC, datetime
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ import httpx
34
+
35
+ from dibs._store import Store, Thing, ts, validate_thing_name
36
+
37
+ log = logging.getLogger("dibs")
38
+
39
+ CONFIG_SOURCE = "config"
40
+
41
+
42
+ @dataclass
43
+ class Config:
44
+ things: list[Thing] = field(default_factory=list)
45
+ pixies: list[str] = field(default_factory=list)
46
+ sync_interval: int = 60
47
+
48
+
49
+ def load_config(path: Path) -> Config:
50
+ """Read the config file; a missing file is an empty config, so a
51
+ fresh install starts with no things rather than failing."""
52
+ if not path.exists():
53
+ return Config()
54
+ try:
55
+ raw = tomllib.loads(path.read_text())
56
+ except tomllib.TOMLDecodeError as exc:
57
+ raise ValueError(f"{path}: {exc}") from exc
58
+ interval = raw.get("sync_interval", 60)
59
+ if not isinstance(interval, int) or interval < 5:
60
+ raise ValueError(f"{path}: sync_interval must be a whole number of seconds, at least 5")
61
+ pixies = []
62
+ for i, entry in enumerate(raw.get("pixie", [])):
63
+ url = entry.get("url") if isinstance(entry, dict) else None
64
+ if not isinstance(url, str) or not url.startswith(("http://", "https://")):
65
+ raise ValueError(f'{path}: pixie #{i + 1} needs a url = "http://..."')
66
+ pixies.append(url.rstrip("/"))
67
+ things = []
68
+ seen: set[str] = set()
69
+ for i, entry in enumerate(raw.get("thing", [])):
70
+ if not isinstance(entry, dict) or "name" not in entry:
71
+ raise ValueError(f"{path}: thing #{i + 1} needs a name")
72
+ attrs = dict(entry)
73
+ try:
74
+ name = validate_thing_name(str(attrs.pop("name")))
75
+ except ValueError as exc:
76
+ raise ValueError(f"{path}: thing #{i + 1}: {exc}") from exc
77
+ if name in seen:
78
+ raise ValueError(f"{path}: thing {name!r} is listed twice")
79
+ seen.add(name)
80
+ kind = str(attrs.pop("kind", "machine"))
81
+ things.append(Thing(name, kind, CONFIG_SOURCE, attrs))
82
+ return Config(things, pixies, interval)
83
+
84
+
85
+ class PixieSource:
86
+ """A pixie server's machines, as things of kind ``machine``."""
87
+
88
+ def __init__(self, url: str, client: httpx.Client | None = None) -> None:
89
+ self.url = url.rstrip("/")
90
+ self.id = f"pixie:{self.url}"
91
+ self._client = client or httpx.Client(timeout=10)
92
+
93
+ def fetch(self) -> list[Thing] | None:
94
+ """The machines pixie lists now, or ``None`` when pixie cannot
95
+ be reached, so the caller can keep what it had."""
96
+ try:
97
+ r = self._client.get(f"{self.url}/machines")
98
+ r.raise_for_status()
99
+ machines = r.json()["machines"]
100
+ except (httpx.HTTPError, ValueError, KeyError) as exc:
101
+ log.warning("pixie %s: %s; keeping its things from the last sync", self.url, exc)
102
+ return None
103
+ things = []
104
+ for m in machines:
105
+ try:
106
+ name = validate_thing_name(str(m.get("name", "")))
107
+ except ValueError:
108
+ continue # a pixie from before machine names; nothing to call it
109
+ attrs: dict[str, Any] = {"mac": m["mac"], "pixie": self.url}
110
+ for key in ("boot_mode", "labels", "last_seen_ip"):
111
+ if m.get(key):
112
+ attrs[key] = m[key]
113
+ things.append(Thing(name, "machine", self.id, attrs))
114
+ return things
115
+
116
+ def info(self, name: str) -> dict[str, Any] | None:
117
+ """pixie's description of one machine, or ``None`` if it has none."""
118
+ try:
119
+ r = self._client.get(f"{self.url}/machines/{name}/info")
120
+ if r.status_code == 404:
121
+ return None
122
+ r.raise_for_status()
123
+ info: dict[str, Any] = r.json()
124
+ return info
125
+ except (httpx.HTTPError, ValueError) as exc:
126
+ log.warning("pixie %s: info for %s: %s", self.url, name, exc)
127
+ return None
128
+
129
+
130
+ def sync(
131
+ store: Store, config: Config, pixies: list[PixieSource], *, now: datetime | None = None
132
+ ) -> None:
133
+ """Rebuild the things table from every source."""
134
+ stamp = ts(now or datetime.now(UTC))
135
+ previous = store.list_things()
136
+ merged: dict[str, Thing] = {}
137
+ for source in pixies:
138
+ fetched = source.fetch()
139
+ if fetched is None:
140
+ fetched = [t for t in previous if t.source == source.id]
141
+ else:
142
+ for t in fetched:
143
+ t.synced_at = stamp
144
+ for t in fetched:
145
+ merged.setdefault(t.name, t)
146
+ for t in config.things:
147
+ base = merged.get(t.name)
148
+ if base is not None:
149
+ base.attrs = {**base.attrs, **t.attrs}
150
+ base.kind = t.kind
151
+ else:
152
+ merged[t.name] = Thing(t.name, t.kind, t.source, dict(t.attrs), stamp)
153
+ store.replace_things(sorted(merged.values(), key=lambda t: t.name))
dibs/_sshsig.py ADDED
@@ -0,0 +1,87 @@
1
+ # SPDX-FileCopyrightText: Simon A. F. Lund <os@safl.dk>
2
+ #
3
+ # SPDX-License-Identifier: BSD-3-Clause
4
+
5
+ """SSH public keys and ``ssh-keygen -Y`` signatures.
6
+
7
+ Signature checking is left to OpenSSH, the reference implementation of
8
+ the format, rather than reimplemented here. Signatures are made and
9
+ checked in the ``dibs`` namespace, so one made for dibs cannot be
10
+ passed off as, say, a git commit signature, or the other way round.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import subprocess
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+ NAMESPACE = "dibs"
20
+
21
+ _KEY_TYPES = frozenset(
22
+ {
23
+ "ssh-ed25519",
24
+ "ssh-rsa",
25
+ "ecdsa-sha2-nistp256",
26
+ "ecdsa-sha2-nistp384",
27
+ "ecdsa-sha2-nistp521",
28
+ "sk-ssh-ed25519@openssh.com",
29
+ "sk-ecdsa-sha2-nistp256@openssh.com",
30
+ }
31
+ )
32
+ _SIGNATURE_MAX = 8192
33
+
34
+
35
+ def parse_pubkey(raw: str) -> tuple[str, str]:
36
+ """``(key, fingerprint)`` for one OpenSSH public key line. ``key`` is
37
+ the ``<type> <base64>`` pair with any comment dropped; raises
38
+ :class:`ValueError` on anything ``ssh-keygen`` does not accept."""
39
+ parts = (raw or "").strip().split()
40
+ if len(parts) < 2 or "\n" in raw.strip() or parts[0] not in _KEY_TYPES:
41
+ raise ValueError(
42
+ "pubkey must be one OpenSSH public key line, e.g. the contents of id_ed25519.pub"
43
+ )
44
+ key = f"{parts[0]} {parts[1]}"
45
+ out = subprocess.run(
46
+ ["ssh-keygen", "-l", "-E", "sha256", "-f", "-"],
47
+ input=key + "\n",
48
+ capture_output=True,
49
+ text=True,
50
+ timeout=10,
51
+ )
52
+ if out.returncode != 0:
53
+ raise ValueError("pubkey is not a valid OpenSSH public key")
54
+ return key, out.stdout.split()[1]
55
+
56
+
57
+ def verify(*, principal: str, key: str, message: str, signature: str) -> bool:
58
+ """True when ``signature`` is a ``dibs``-namespace signature of
59
+ ``message`` by ``key``. ``principal`` must already be validated: it
60
+ is written into the ``allowed_signers`` line."""
61
+ if len(signature) > _SIGNATURE_MAX or "BEGIN SSH SIGNATURE" not in signature:
62
+ return False
63
+ with tempfile.TemporaryDirectory(prefix="dibs-sshsig-") as tmp:
64
+ allowed = Path(tmp) / "allowed_signers"
65
+ allowed.write_text(f'{principal} namespaces="{NAMESPACE}" {key}\n')
66
+ sig = Path(tmp) / "message.sig"
67
+ sig.write_text(signature)
68
+ out = subprocess.run(
69
+ [
70
+ "ssh-keygen",
71
+ "-Y",
72
+ "verify",
73
+ "-f",
74
+ str(allowed),
75
+ "-I",
76
+ principal,
77
+ "-n",
78
+ NAMESPACE,
79
+ "-s",
80
+ str(sig),
81
+ ],
82
+ input=message,
83
+ capture_output=True,
84
+ text=True,
85
+ timeout=10,
86
+ )
87
+ return out.returncode == 0
dibs/_store.py ADDED
@@ -0,0 +1,327 @@
1
+ # SPDX-FileCopyrightText: Simon A. F. Lund <os@safl.dk>
2
+ #
3
+ # SPDX-License-Identifier: BSD-3-Clause
4
+
5
+ """Things and the claims on them.
6
+
7
+ A **thing** is whatever people share and claim, mostly lab machines. The
8
+ things table is rebuilt from the sources on every sync (see
9
+ :mod:`dibs._sources`); dibs never edits it otherwise. **Claims** live in
10
+ a table of their own, keyed by thing name, so a claim outlives a sync in
11
+ which its source was unreachable or briefly forgot the thing.
12
+
13
+ Every surface (``dibs``, the web page, the login hook on a machine)
14
+ prints a claim as the same sentence, from :func:`sentence`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import builtins
20
+ import contextlib
21
+ import json
22
+ import re
23
+ import sqlite3
24
+ import threading
25
+ from collections.abc import Generator, Sequence
26
+ from dataclasses import dataclass, field
27
+ from datetime import UTC, datetime, timedelta
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ _WRITE_LOCK = threading.Lock()
32
+
33
+ # What people type and put in configs next to the hostname, so a host
34
+ # label: lowercase a-z, 0-9 and -, at most 63 chars.
35
+ THING_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
36
+
37
+ PURPOSE_MAX = 128
38
+ ASSISTED_BY_MAX = 64
39
+
40
+ _SCHEMA = """
41
+ CREATE TABLE IF NOT EXISTS things (
42
+ name TEXT PRIMARY KEY,
43
+ kind TEXT NOT NULL,
44
+ source TEXT NOT NULL,
45
+ attrs TEXT NOT NULL DEFAULT '{}',
46
+ synced_at TEXT NOT NULL
47
+ );
48
+ CREATE TABLE IF NOT EXISTS claims (
49
+ thing TEXT PRIMARY KEY,
50
+ holder TEXT NOT NULL,
51
+ assisted_by TEXT NOT NULL DEFAULT '',
52
+ purpose TEXT NOT NULL,
53
+ claimed_at TEXT NOT NULL,
54
+ expires_at TEXT NOT NULL DEFAULT ''
55
+ );
56
+ """
57
+
58
+
59
+ def ts(at: datetime) -> str:
60
+ return at.strftime("%Y-%m-%dT%H:%M:%SZ")
61
+
62
+
63
+ def parse_ts(raw: str) -> datetime:
64
+ return datetime.strptime(raw, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
65
+
66
+
67
+ def validate_thing_name(raw: str) -> str:
68
+ name = (raw or "").strip().lower()
69
+ if not THING_NAME_RE.match(name):
70
+ raise ValueError(
71
+ f"name {name!r} must be a hostname label: a-z / 0-9 / -, "
72
+ "1-63 chars, no leading or trailing -"
73
+ )
74
+ return name
75
+
76
+
77
+ def _one_line(field_name: str, raw: str, limit: int) -> str:
78
+ value = (raw or "").strip()
79
+ if not value:
80
+ raise ValueError(f"{field_name} must not be empty")
81
+ if len(value) > limit:
82
+ raise ValueError(f"{field_name} must be at most {limit} chars")
83
+ # Printed into other people's terminals by the login hook, so no
84
+ # control characters (an ESC would let a claim restyle the terminal).
85
+ if not value.isprintable():
86
+ raise ValueError(f"{field_name} must be a single line of printable text")
87
+ return value
88
+
89
+
90
+ def validate_purpose(raw: str) -> str:
91
+ return _one_line("purpose", raw, PURPOSE_MAX)
92
+
93
+
94
+ def validate_assisted_by(raw: str) -> str:
95
+ """Optional, so blank passes."""
96
+ return _one_line("assisted_by", raw, ASSISTED_BY_MAX) if (raw or "").strip() else ""
97
+
98
+
99
+ @dataclass
100
+ class Thing:
101
+ name: str
102
+ kind: str
103
+ source: str
104
+ """``config``, or ``pixie:<url>`` for a thing a pixie server reported."""
105
+ attrs: dict[str, Any] = field(default_factory=dict)
106
+ synced_at: str = ""
107
+
108
+
109
+ @dataclass
110
+ class Claim:
111
+ thing: str
112
+ holder: str
113
+ """The dibs user who holds the claim."""
114
+ purpose: str
115
+ claimed_at: str
116
+ assisted_by: str = ""
117
+ """The agent the holder claimed through, in ``Agent:model`` form like
118
+ an ``Assisted-by:`` trailer; blank for a claim made by hand."""
119
+ expires_at: str = ""
120
+ """Blank for a claim that never expires."""
121
+
122
+ def is_live(self, now: datetime) -> bool:
123
+ """A claim past its expiry counts as no claim: the thing is free
124
+ to take without ``force``, and nothing has to sweep it up."""
125
+ return not self.expires_at or parse_ts(self.expires_at) > now
126
+
127
+ def to_dict(self) -> dict[str, str]:
128
+ out = {
129
+ "holder": self.holder,
130
+ "purpose": self.purpose,
131
+ "claimed_at": self.claimed_at,
132
+ }
133
+ if self.assisted_by:
134
+ out["assisted_by"] = self.assisted_by
135
+ if self.expires_at:
136
+ out["expires_at"] = self.expires_at
137
+ return out
138
+
139
+
140
+ class ClaimConflict(ValueError):
141
+ """One or more things are claimed by another user."""
142
+
143
+ def __init__(self, blockers: Sequence[Claim], now: datetime) -> None:
144
+ self.blockers = list(blockers)
145
+ self.sentences = [sentence(c.thing, c, now) for c in blockers]
146
+ super().__init__("; ".join(self.sentences))
147
+
148
+
149
+ def remaining(expires_at: str, now: datetime) -> str:
150
+ """``never`` or the time left as ``2d 3h`` / ``1h 12m`` / ``4m``, to
151
+ the two largest units. Rounds up to the minute, so a claim made for
152
+ ``2h`` reads ``2h`` rather than ``1h 59m`` a second later."""
153
+ if not expires_at:
154
+ return "never"
155
+ secs = max(0, int((parse_ts(expires_at) - now).total_seconds()))
156
+ days, rest = divmod(-(-secs // 60), 1440)
157
+ hours, minutes = divmod(rest, 60)
158
+ parts = [f"{n}{u}" for n, u in ((days, "d"), (hours, "h"), (minutes, "m")) if n]
159
+ return " ".join(parts[:2]) or "<1m"
160
+
161
+
162
+ def sentence(name: str, claim: Claim | None, now: datetime) -> str:
163
+ if claim is None or not claim.is_live(now):
164
+ return f"'{name}' is free"
165
+ who = f"'{claim.holder}'"
166
+ if claim.assisted_by:
167
+ who += f" assisted by '{claim.assisted_by}'"
168
+ return (
169
+ f"{who} has claimed '{name}' for '{claim.purpose}' "
170
+ f"expires in: '{remaining(claim.expires_at, now)}'"
171
+ )
172
+
173
+
174
+ class Store:
175
+ def __init__(self, db_path: Path) -> None:
176
+ self.db_path = Path(db_path)
177
+ with self._conn() as conn:
178
+ conn.executescript(_SCHEMA)
179
+
180
+ @contextlib.contextmanager
181
+ def _conn(self) -> Generator[sqlite3.Connection]:
182
+ conn = sqlite3.connect(str(self.db_path))
183
+ conn.execute("PRAGMA busy_timeout = 5000")
184
+ conn.row_factory = sqlite3.Row
185
+ try:
186
+ yield conn
187
+ conn.commit()
188
+ finally:
189
+ conn.close()
190
+
191
+ # ---- things -----------------------------------------------------
192
+
193
+ def replace_things(self, things: Sequence[Thing]) -> None:
194
+ """Make ``things`` the whole list, in one transaction."""
195
+ with _WRITE_LOCK, self._conn() as conn:
196
+ conn.execute("DELETE FROM things")
197
+ conn.executemany(
198
+ "INSERT INTO things (name, kind, source, attrs, synced_at) VALUES (?, ?, ?, ?, ?)",
199
+ [(t.name, t.kind, t.source, json.dumps(t.attrs), t.synced_at) for t in things],
200
+ )
201
+
202
+ def list_things(self) -> builtins.list[Thing]:
203
+ with self._conn() as conn:
204
+ rows = conn.execute("SELECT * FROM things ORDER BY name").fetchall()
205
+ return [_thing(r) for r in rows]
206
+
207
+ def get_thing(self, name: str) -> Thing | None:
208
+ with self._conn() as conn:
209
+ row = conn.execute(
210
+ "SELECT * FROM things WHERE name = ?", ((name or "").strip().lower(),)
211
+ ).fetchone()
212
+ return _thing(row) if row else None
213
+
214
+ def thing_by_mac(self, macs: Sequence[str]) -> Thing | None:
215
+ wanted = {m.strip().lower() for m in macs if m.strip()}
216
+ for thing in self.list_things():
217
+ if str(thing.attrs.get("mac", "")).lower() in wanted:
218
+ return thing
219
+ return None
220
+
221
+ # ---- claims -----------------------------------------------------
222
+
223
+ def get_claim(self, name: str) -> Claim | None:
224
+ with self._conn() as conn:
225
+ row = conn.execute("SELECT * FROM claims WHERE thing = ?", (name,)).fetchone()
226
+ return Claim(**dict(row)) if row else None
227
+
228
+ def list_claims(self) -> dict[str, Claim]:
229
+ with self._conn() as conn:
230
+ rows = conn.execute("SELECT * FROM claims").fetchall()
231
+ return {r["thing"]: Claim(**dict(r)) for r in rows}
232
+
233
+ def claim(
234
+ self,
235
+ names: Sequence[str],
236
+ *,
237
+ holder: str,
238
+ purpose: str,
239
+ assisted_by: str = "",
240
+ expires_in: int | None = None,
241
+ force: bool = False,
242
+ now: datetime | None = None,
243
+ ) -> builtins.list[tuple[Claim | None, Claim]]:
244
+ """Claim every thing in ``names`` for ``holder``, or none of them:
245
+ if any holds a live claim by another user (and ``force`` is off)
246
+ this raises :class:`ClaimConflict` and changes nothing.
247
+ Ownership is the user's: claiming again as the same holder, with
248
+ or without an agent, updates the purpose, expiry and
249
+ ``assisted_by`` and keeps ``claimed_at``. ``expires_in`` is
250
+ seconds from now; ``None`` never expires. Returns ``(before,
251
+ after)`` pairs so the caller can log what a ``force`` took over.
252
+ Callers check that the things exist."""
253
+ purpose = validate_purpose(purpose)
254
+ assisted_by = validate_assisted_by(assisted_by)
255
+ if expires_in is not None and expires_in <= 0:
256
+ raise ValueError("expires_in must be a positive number of seconds")
257
+ at = now or datetime.now(UTC)
258
+ expires_at = ts(at + timedelta(seconds=expires_in)) if expires_in is not None else ""
259
+ unique = list(dict.fromkeys(names))
260
+ with _WRITE_LOCK, self._conn() as conn:
261
+ before = {n: self._claim_row(conn, n) for n in unique}
262
+ live = {n: c for n, c in before.items() if c is not None and c.is_live(at)}
263
+ blockers = [c for c in live.values() if c.holder != holder]
264
+ if blockers and not force:
265
+ raise ClaimConflict(blockers, at)
266
+ out: builtins.list[tuple[Claim | None, Claim]] = []
267
+ for n in unique:
268
+ prior = live.get(n)
269
+ since = prior.claimed_at if prior and prior.holder == holder else ts(at)
270
+ after = Claim(n, holder, purpose, since, assisted_by, expires_at)
271
+ conn.execute(
272
+ "INSERT OR REPLACE INTO claims "
273
+ "(thing, holder, assisted_by, purpose, claimed_at, expires_at) "
274
+ "VALUES (?, ?, ?, ?, ?, ?)",
275
+ (n, holder, assisted_by, purpose, since, expires_at),
276
+ )
277
+ out.append((prior, after))
278
+ return out
279
+
280
+ def release(
281
+ self,
282
+ names: Sequence[str],
283
+ *,
284
+ holder: str,
285
+ force: bool = False,
286
+ now: datetime | None = None,
287
+ ) -> builtins.list[Claim]:
288
+ """Release the claims on ``names``, all or nothing like
289
+ :meth:`claim`: releasing another user's live claim needs
290
+ ``force``. Expired claims are cleared whoever holds them. Returns
291
+ the live claims released."""
292
+ at = now or datetime.now(UTC)
293
+ unique = list(dict.fromkeys(names))
294
+ with _WRITE_LOCK, self._conn() as conn:
295
+ before = [c for c in (self._claim_row(conn, n) for n in unique) if c is not None]
296
+ blockers = [c for c in before if c.is_live(at) and c.holder != holder]
297
+ if blockers and not force:
298
+ raise ClaimConflict(blockers, at)
299
+ conn.executemany("DELETE FROM claims WHERE thing = ?", [(c.thing,) for c in before])
300
+ return [c for c in before if c.is_live(at)]
301
+
302
+ def claimed_by(
303
+ self, holder: str, assisted_by: str = "", *, now: datetime | None = None
304
+ ) -> builtins.list[Claim]:
305
+ """Live claims held by ``holder`` through exactly ``assisted_by``
306
+ (blank: claimed by hand), so an agent's ``release --mine`` leaves
307
+ its human's own claims alone."""
308
+ at = now or datetime.now(UTC)
309
+ with self._conn() as conn:
310
+ rows = conn.execute(
311
+ "SELECT * FROM claims WHERE holder = ? AND assisted_by = ? ORDER BY thing",
312
+ (holder, assisted_by.strip()),
313
+ ).fetchall()
314
+ return [c for c in (Claim(**dict(r)) for r in rows) if c.is_live(at)]
315
+
316
+ @staticmethod
317
+ def _claim_row(conn: sqlite3.Connection, name: str) -> Claim | None:
318
+ row = conn.execute("SELECT * FROM claims WHERE thing = ?", (name,)).fetchone()
319
+ return Claim(**dict(row)) if row else None
320
+
321
+
322
+ def _thing(r: sqlite3.Row) -> Thing:
323
+ try:
324
+ attrs = json.loads(r["attrs"] or "{}")
325
+ except ValueError:
326
+ attrs = {}
327
+ return Thing(r["name"], r["kind"], r["source"], attrs, r["synced_at"])