lab-kit-cli 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,126 @@
1
+ """`lab-kit init`: lab.yaml, ops/, experiments/, .lab/, the skills and the agent roles, and the lab pack on.
2
+
3
+ Before a folio library exists, `lab-kit init` installs only the method, the skills and the roles,
4
+ so the set-up-lab skill is there to read: it creates the library and runs `lab-kit init --library` again.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import shutil
12
+ from pathlib import Path
13
+
14
+ from folio import library as folio_library
15
+ from folio.commands.charter_cmds import pack_on
16
+ from folio.errors import FolioError
17
+
18
+ from .. import __version__, data
19
+ from ..errors import LabError
20
+ from ..frozen import FROZEN
21
+ from ..lab import LAB_YAML
22
+ from ..ops import MISSIONS, STATE
23
+
24
+ _LAB_YAML = """\
25
+ lab-kit: {version} # the version this lab is checked with
26
+ library: {library} # where folio.yaml is; "." for the root
27
+ front: # the front door's id: a project document
28
+ frozen: [] # surfaces never edited in place; record each with `lab-kit freeze`
29
+ tools: [] # lab-wide scripts; each must pass --selftest
30
+ rederive_timeout: 120 # seconds per re-derive command
31
+ """
32
+
33
+ _STATE = """\
34
+ # State
35
+
36
+ Active mission: none
37
+
38
+ Next: the operator's first objective, planned through the plan-mission skill.
39
+ """
40
+
41
+
42
+ def _link(root: Path, link: str, wanted: str) -> list[str]:
43
+ path = root / link
44
+ if path.is_symlink():
45
+ if os.readlink(path) != wanted:
46
+ raise LabError(f"{link} links to {os.readlink(path)}, not {wanted}; move it aside first")
47
+ return []
48
+ if path.exists():
49
+ raise LabError(f"{link} exists and is not a link to {wanted}; move it aside first")
50
+ path.parent.mkdir(parents=True, exist_ok=True)
51
+ path.symlink_to(wanted)
52
+ return [f"linked {link} -> {wanted}"]
53
+
54
+
55
+ def install(root: Path) -> list[str]:
56
+ """Copy the method, the skills and the roles into the lab, as this version ships them."""
57
+ changed: list[str] = []
58
+ for src, dest in data.shipped_files():
59
+ target = root / dest
60
+ if target.is_file() and target.read_bytes() == src.read_bytes():
61
+ continue
62
+ existed = target.exists()
63
+ target.parent.mkdir(parents=True, exist_ok=True)
64
+ shutil.copyfile(src, target)
65
+ changed.append(f"{'updated' if existed else 'created'} {dest}")
66
+ changed.extend(_link(root, ".claude/skills", os.path.join("..", ".agents", "skills")))
67
+ changed.extend(_link(root, ".claude/agents", os.path.join("..", ".agents", "agents")))
68
+ return changed
69
+
70
+
71
+ def _write_new(root: Path, rel: str, text: str) -> list[str]:
72
+ path = root / rel
73
+ if path.exists():
74
+ return []
75
+ path.parent.mkdir(parents=True, exist_ok=True)
76
+ path.write_text(text, encoding="utf-8")
77
+ return [f"created {rel}"]
78
+
79
+
80
+ SKILLS_ONLY = ("No folio library here yet: installed lab-kit's method, skills and roles only. "
81
+ "Next: read .agents/skills/set-up-lab/SKILL.md and follow it.")
82
+
83
+
84
+ def init(root: Path, library: str | None) -> list[str]:
85
+ root = root.resolve()
86
+ existing = root / LAB_YAML
87
+ changed: list[str] = []
88
+ if library is None and not existing.is_file() and not (root / "folio.yaml").is_file():
89
+ return install(root) + [SKILLS_ONLY]
90
+ library = library or "."
91
+ if existing.is_file():
92
+ text = existing.read_text(encoding="utf-8")
93
+ updated = re.sub(r"(?m)^lab-kit:\s*\S+", f"lab-kit: {__version__}", text, count=1)
94
+ if updated != text:
95
+ existing.write_text(updated, encoding="utf-8")
96
+ changed.append(f"updated {LAB_YAML}: lab-kit {__version__}")
97
+ library = _library_of(updated)
98
+ lib_dir = (root / library).resolve()
99
+ if not (lib_dir / "folio.yaml").is_file():
100
+ raise LabError(f"`{library}` holds no folio.yaml; set the library up with folio first")
101
+ try:
102
+ lib = folio_library.load_at(lib_dir)
103
+ except FolioError as exc:
104
+ raise LabError(f"the library in `{library}` does not load: {exc}") from exc
105
+ if lib.charter.root_dir != root:
106
+ depth = len(lib_dir.relative_to(root).parts)
107
+ raise LabError(f"{library}/folio.yaml: `root` resolves to {lib.charter.root_dir}, not the lab root; "
108
+ f"run `folio config set root {'/'.join(['..'] * depth) or '.'}` in the library first")
109
+ if not existing.is_file():
110
+ changed.extend(_write_new(root, LAB_YAML, _LAB_YAML.format(version=__version__, library=library)))
111
+ changed.extend(_write_new(root, STATE, _STATE))
112
+ changed.extend(_write_new(root, f"{MISSIONS}/.gitkeep", ""))
113
+ changed.extend(_write_new(root, "experiments/.gitkeep", ""))
114
+ changed.extend(_write_new(root, FROZEN, ""))
115
+ changed.extend(install(root))
116
+ if "lab" not in lib.charter.pack_names:
117
+ lines = pack_on(lib, "lab").splitlines()
118
+ changed.extend(f"{line} (in {library})" if library not in (".", "") else line for line in lines)
119
+ return changed
120
+
121
+
122
+ def _library_of(text: str) -> str:
123
+ match = re.search(r"(?m)^library:\s*(\S+)", text)
124
+ if match is None:
125
+ raise LabError(f"{LAB_YAML} names no `library`")
126
+ return match.group(1)
@@ -0,0 +1,26 @@
1
+ """`lab-kit status`: the state, the active mission, live and orphaned runs, and protocols by status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .. import ops, records
6
+ from ..lab import Lab
7
+
8
+
9
+ def status(lab: Lab) -> list[str]:
10
+ lines: list[str] = []
11
+ state = lab.root / ops.STATE
12
+ if state.is_file():
13
+ text = state.read_text(encoding="utf-8")
14
+ lines.append(f"Active mission: {ops.active_mission(text) or 'not named in ' + ops.STATE}")
15
+ lines.append(f"State ({ops.STATE}):")
16
+ lines.extend(" " + line for line in text.strip().splitlines())
17
+ else:
18
+ lines.append(f"No {ops.STATE}.")
19
+ live = [r for r in records.runs(lab) if r.state in ("running", "orphaned")]
20
+ lines.append("Live and orphaned runs:" if live else "Live and orphaned runs: none")
21
+ lines.extend(f" {r.slug}/{r.run_id} {r.state} started {r.data.get('started')}" for r in live)
22
+ protocols = [d for d in lab.library().documents if d.is_a("protocol")]
23
+ for wanted in ("draft", "locked"):
24
+ found = sorted(d.id for d in protocols if d.status == wanted)
25
+ lines.append(f"Protocols {wanted}: {', '.join(found) if found else 'none'}")
26
+ return lines
lab_kit/data.py ADDED
@@ -0,0 +1,49 @@
1
+ """Where lab-kit's shipped method, skills and agent roles live."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .errors import LabError
8
+
9
+ SKILLS = ("set-up-lab", "plan-mission", "run-mission", "experiment", "review")
10
+ AGENTS = ("scout", "runner", "reviewer", "reporter")
11
+ METHOD = ("DISCIPLINE.md", "LADDER.md")
12
+
13
+
14
+ def data_root() -> Path:
15
+ """The folder holding `method/`, `skills/` and `agents/`.
16
+
17
+ An installed wheel carries them in `lab_kit/_data/`; a source checkout has
18
+ them at the repository root, two levels above this package.
19
+ """
20
+ packaged = Path(__file__).resolve().parent / "_data"
21
+ if (packaged / "method").is_dir():
22
+ return packaged
23
+ checkout = Path(__file__).resolve().parents[2]
24
+ if (checkout / "method").is_dir():
25
+ return checkout
26
+ raise LabError(f"cannot find lab-kit's shipped method: looked in {packaged} and {checkout}")
27
+
28
+
29
+ def shipped(name: str) -> Path:
30
+ if name not in ("method", "skills", "agents"):
31
+ raise ValueError(name)
32
+ return data_root() / name
33
+
34
+
35
+ def shipped_files() -> list[tuple[Path, str]]:
36
+ """Every shipped file a lab carries, as (source, path in the lab from its root)."""
37
+ out: list[tuple[Path, str]] = []
38
+ for name in METHOD:
39
+ out.append((shipped("method") / name, f".lab/method/{name}"))
40
+ for skill in SKILLS:
41
+ folder = shipped("skills") / skill
42
+ for src in sorted(p for p in folder.rglob("*") if p.is_file()):
43
+ out.append((src, f".agents/skills/{skill}/{src.relative_to(folder).as_posix()}"))
44
+ for agent in AGENTS:
45
+ out.append((shipped("agents") / f"{agent}.md", f".agents/agents/{agent}.md"))
46
+ for src, _ in out:
47
+ if not src.is_file():
48
+ raise LabError(f"lab-kit's shipped file {src} is missing")
49
+ return out
lab_kit/errors.py ADDED
@@ -0,0 +1,2 @@
1
+ class LabError(Exception):
2
+ """A problem lab-kit cannot work around. The command line prints it and exits 2."""
lab_kit/frozen.py ADDED
@@ -0,0 +1,48 @@
1
+ """Frozen surfaces: the hashes `lab-kit freeze` records in `.lab/frozen.sha256`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from . import records
8
+ from .errors import LabError
9
+ from .lab import Lab
10
+
11
+ FROZEN = ".lab/frozen.sha256"
12
+
13
+
14
+ def surface_hashes(lab: Lab, surface: str) -> dict[str, str]:
15
+ """Every file of a surface (a file, or a folder and all it holds), by its path from the lab root."""
16
+ target = lab.root / surface
17
+ if not target.exists():
18
+ raise LabError(f"frozen surface `{surface}` does not exist")
19
+ return records.tree_hashes(target, lab.root)
20
+
21
+
22
+ def belongs(path: str, surface: str) -> bool:
23
+ surface = surface.rstrip("/")
24
+ return path == surface or path.startswith(surface + "/")
25
+
26
+
27
+ def recorded(lab: Lab) -> dict[str, str]:
28
+ path = lab.root / FROZEN
29
+ if not path.is_file():
30
+ return {}
31
+ return records.parse_hashes(path.read_text(encoding="utf-8"), FROZEN)
32
+
33
+
34
+ def freeze(lab: Lab, surface: str) -> list[str]:
35
+ """Record a surface's hashes, replacing what was recorded for it before."""
36
+ surface = Path(surface).as_posix().rstrip("/")
37
+ listed = [s.rstrip("/") for s in lab.settings.frozen]
38
+ if surface not in listed:
39
+ raise LabError(f"`{surface}` is not listed under `frozen:` in lab.yaml; list it there first")
40
+ hashes = surface_hashes(lab, surface)
41
+ if not hashes:
42
+ raise LabError(f"frozen surface `{surface}` holds no file")
43
+ kept = {p: d for p, d in recorded(lab).items() if not belongs(p, surface)}
44
+ kept.update(hashes)
45
+ out = lab.root / FROZEN
46
+ out.parent.mkdir(parents=True, exist_ok=True)
47
+ out.write_text(records.format_hashes(kept), encoding="utf-8")
48
+ return [f"updated {FROZEN}: recorded {len(hashes)} file(s) of {surface}"]
lab_kit/gitlog.py ADDED
@@ -0,0 +1,55 @@
1
+ """Read the history of a lab file from git: each committed version, oldest first, then the working tree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ from .errors import LabError
10
+
11
+ WORKING = "working tree"
12
+
13
+
14
+ def _git(root: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
15
+ result = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, check=False)
16
+ if check and result.returncode != 0:
17
+ raise LabError(f"git {' '.join(args)} failed: {result.stderr.strip()}")
18
+ return result
19
+
20
+
21
+ def in_git(root: Path) -> bool:
22
+ if shutil.which("git") is None:
23
+ return False
24
+ return _git(root, "rev-parse", "--is-inside-work-tree", check=False).stdout.strip() == "true"
25
+
26
+
27
+ def _has_head(root: Path) -> bool:
28
+ return _git(root, "rev-parse", "--verify", "-q", "HEAD", check=False).returncode == 0
29
+
30
+
31
+ def tracked(root: Path, folder: str) -> list[str]:
32
+ """Every path under `folder` that any commit touched, from `root`, including deleted ones."""
33
+ if not _has_head(root):
34
+ return []
35
+ out = _git(root, "log", "--format=", "--name-only", "--relative", "--", folder).stdout
36
+ return sorted({line.strip() for line in out.splitlines() if line.strip()})
37
+
38
+
39
+ def versions(root: Path, path: str) -> list[tuple[str, str | None]]:
40
+ """(commit, text) for every commit that touched `path`, oldest first, then the working tree if it differs.
41
+
42
+ The text is None where the file did not exist.
43
+ """
44
+ out: list[tuple[str, str | None]] = []
45
+ if _has_head(root):
46
+ shas = _git(root, "log", "--format=%H", "--", path).stdout.split()
47
+ for sha in reversed(shas):
48
+ shown = _git(root, "show", f"{sha}:./{path}", check=False)
49
+ out.append((sha, shown.stdout if shown.returncode == 0 else None))
50
+ target = root / path
51
+ now = target.read_text(encoding="utf-8") if target.is_file() else None
52
+ if not out or out[-1][1] != now:
53
+ if out or now is not None:
54
+ out.append((WORKING, now))
55
+ return out
lab_kit/lab.py ADDED
@@ -0,0 +1,135 @@
1
+ """A lab: its root, its settings in `lab.yaml`, and the folio library beside them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+ from folio import library as folio_library
11
+ from folio.errors import FolioError
12
+ from folio.library import Library
13
+
14
+ from .errors import LabError
15
+
16
+ LAB_YAML = "lab.yaml"
17
+ _KEYS = ("lab-kit", "library", "front", "frozen", "tools", "rederive_timeout")
18
+
19
+
20
+ @dataclass
21
+ class Settings:
22
+ version: str
23
+ library: str
24
+ front: str | None
25
+ frozen: list[str]
26
+ tools: list[str]
27
+ rederive_timeout: int
28
+
29
+
30
+ @dataclass
31
+ class Lab:
32
+ root: Path
33
+ settings: Settings
34
+ cache: dict[str, Any] = field(default_factory=dict)
35
+
36
+ @property
37
+ def library_dir(self) -> Path:
38
+ return (self.root / self.settings.library).resolve()
39
+
40
+ def library(self) -> Library:
41
+ """The folio library, loaded once. Its charter's `root` must be the lab root."""
42
+ if "library" not in self.cache:
43
+ try:
44
+ lib = folio_library.load_at(self.library_dir)
45
+ except FolioError as exc:
46
+ raise LabError(f"the library in `{self.settings.library}` does not load: {exc}") from exc
47
+ if lib.charter.root_dir != self.root:
48
+ raise LabError(
49
+ f"{self.settings.library}/folio.yaml: `root` resolves to {lib.charter.root_dir}, not the lab "
50
+ f"root {self.root}; set it with `folio config set root {_relative_root(self)}` in the library")
51
+ self.cache["library"] = lib
52
+ return self.cache["library"]
53
+
54
+ def reload(self) -> Library:
55
+ self.cache.pop("library", None)
56
+ return self.library()
57
+
58
+ def rel(self, path: Path) -> str:
59
+ """A path from the lab root, with forward slashes."""
60
+ return path.resolve().relative_to(self.root).as_posix()
61
+
62
+ def doc_path(self, doc_file_path: str) -> str:
63
+ """A library file's path from the lab root."""
64
+ if self.settings.library in (".", ""):
65
+ return doc_file_path
66
+ return f"{Path(self.settings.library).as_posix().rstrip('/')}/{doc_file_path}"
67
+
68
+ @property
69
+ def experiments(self) -> Path:
70
+ return self.root / "experiments"
71
+
72
+ def experiment_slugs(self) -> list[str]:
73
+ if not self.experiments.is_dir():
74
+ return []
75
+ return sorted(p.name for p in self.experiments.iterdir() if p.is_dir() and not p.name.startswith("."))
76
+
77
+
78
+ def _relative_root(lab: Lab) -> str:
79
+ depth = len(Path(lab.settings.library).parts)
80
+ return "/".join([".."] * depth) or "."
81
+
82
+
83
+ def find_root(start: Path) -> Path:
84
+ here = start.resolve()
85
+ for folder in (here, *here.parents):
86
+ if (folder / LAB_YAML).is_file():
87
+ return folder
88
+ raise LabError(f"no {LAB_YAML} at or above {here}; run `lab-kit init` in the lab's root first")
89
+
90
+
91
+ def parse_settings(raw: Any, where: str) -> Settings:
92
+ if not isinstance(raw, dict):
93
+ raise LabError(f"{where}: must be a mapping")
94
+ unknown = sorted(set(raw) - set(_KEYS))
95
+ if unknown:
96
+ raise LabError(f"{where}: unknown key(s) {', '.join(unknown)}; the keys are {', '.join(_KEYS)}")
97
+ version = raw.get("lab-kit")
98
+ if not isinstance(version, str) or not version:
99
+ raise LabError(f"{where}: `lab-kit` must name the version this lab is checked with, as a string")
100
+ library = raw.get("library")
101
+ if not isinstance(library, str) or not library:
102
+ raise LabError(f"{where}: `library` must name the folder holding folio.yaml (\".\" for the root)")
103
+ front = raw.get("front")
104
+ if front is not None and not isinstance(front, str):
105
+ raise LabError(f"{where}: `front` must be a document id")
106
+ lists = {}
107
+ for key in ("frozen", "tools"):
108
+ value = raw.get(key) or []
109
+ if not isinstance(value, list) or not all(isinstance(v, str) and v for v in value):
110
+ raise LabError(f"{where}: `{key}` must be a list of paths from the lab root")
111
+ for v in value:
112
+ if Path(v).is_absolute() or ".." in Path(v).parts:
113
+ raise LabError(f"{where}: `{key}` path `{v}` must stay inside the lab")
114
+ lists[key] = value
115
+ timeout = raw.get("rederive_timeout", 120)
116
+ if not isinstance(timeout, int) or isinstance(timeout, bool) or timeout <= 0:
117
+ raise LabError(f"{where}: `rederive_timeout` must be a positive whole number of seconds")
118
+ return Settings(version, library, front or None, lists["frozen"], lists["tools"], timeout)
119
+
120
+
121
+ def load_at(root: Path) -> Lab:
122
+ root = root.resolve()
123
+ path = root / LAB_YAML
124
+ try:
125
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
126
+ except yaml.YAMLError as exc:
127
+ raise LabError(f"{LAB_YAML}: not valid YAML: {exc}") from exc
128
+ lab = Lab(root, parse_settings(raw, LAB_YAML))
129
+ if not (lab.library_dir / "folio.yaml").is_file():
130
+ raise LabError(f"{LAB_YAML}: `library: {lab.settings.library}` holds no folio.yaml")
131
+ return lab
132
+
133
+
134
+ def load(start: Path) -> Lab:
135
+ return load_at(find_root(start))
lab_kit/ops.py ADDED
@@ -0,0 +1,94 @@
1
+ """The operator's files: `ops/STATE.md` and the missions under `ops/missions/`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from folio.errors import FolioError
11
+ from folio.frontmatter import split
12
+
13
+ from .errors import LabError
14
+ from .lab import Lab
15
+
16
+ STATE = "ops/STATE.md"
17
+ MISSIONS = "ops/missions"
18
+ LOG_HEADING = re.compile(r"^## Log[ \t]*$", re.M)
19
+ ACTIVE = re.compile(r"^\s*Active mission:\s*(.+?)\s*$", re.M | re.I)
20
+
21
+
22
+ @dataclass
23
+ class Mission:
24
+ path: str # from the lab root
25
+ meta: dict[str, Any]
26
+ text: str
27
+
28
+ @property
29
+ def cap(self) -> float | None:
30
+ cap = self.meta.get("cap")
31
+ if isinstance(cap, (int, float)) and not isinstance(cap, bool):
32
+ return float(cap)
33
+ return None
34
+
35
+ @property
36
+ def approved(self) -> str:
37
+ return str(self.meta.get("approved") or "").strip()
38
+
39
+ @property
40
+ def status(self) -> str:
41
+ return str(self.meta.get("status") or "").strip()
42
+
43
+ def approval_problem(self) -> str | None:
44
+ """Why this mission may not run, or None when it is approved and active."""
45
+ if self.status == "draft":
46
+ return (f"{self.path} is a draft: nothing runs under a mission until the operator approves it "
47
+ "(the plan-mission skill records `approved` and sets `status: active`)")
48
+ if self.status != "active":
49
+ return f"{self.path} has status `{self.status or '(none)'}`, not `active`: a run needs an active mission"
50
+ if not approved_names_a_date(self.approved):
51
+ return f"{self.path} records no approval with who and when (`approved:`); nothing runs before it"
52
+ return None
53
+
54
+
55
+ STATUSES = ("draft", "active", "concluded")
56
+ DATE = re.compile(r"\b\d{4}-\d{2}-\d{2}\b")
57
+
58
+
59
+ def approved_names_a_date(approved: str) -> bool:
60
+ """`approved` says who and when: some words, and a date as YYYY-MM-DD."""
61
+ return bool(DATE.search(approved)) and bool(DATE.sub("", approved).strip(" ,;:.-"))
62
+
63
+
64
+ def mission_files(lab: Lab) -> list[Path]:
65
+ folder = lab.root / MISSIONS
66
+ return sorted(folder.glob("*.md")) if folder.is_dir() else []
67
+
68
+
69
+ def read_mission(lab: Lab, path: Path) -> Mission:
70
+ where = lab.rel(path)
71
+ text = path.read_text(encoding="utf-8")
72
+ try:
73
+ meta, _ = split(text, where)
74
+ except FolioError as exc:
75
+ raise LabError(str(exc)) from exc
76
+ return Mission(where, meta or {}, text)
77
+
78
+
79
+ def log_of(text: str) -> str | None:
80
+ """The mission log: everything after the `## Log` heading, or None without one."""
81
+ match = LOG_HEADING.search(text)
82
+ return text[match.end():] if match else None
83
+
84
+
85
+ def active_mission(text: str) -> str | None:
86
+ """What `Active mission:` names in the state file: a path, `none`, or None when the line is missing."""
87
+ match = ACTIVE.search(text)
88
+ if match is None:
89
+ return None
90
+ value = match.group(1).strip().strip("`*")
91
+ link = re.match(r"^\[[^\]]*\]\(([^)]+)\)$", value)
92
+ if link:
93
+ value = link.group(1)
94
+ return value.strip("`")
lab_kit/protocol.py ADDED
@@ -0,0 +1,100 @@
1
+ """A protocol as lab-kit reads it: its file, status, hash with the status set aside, pins and ids."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+ from folio.documents import Document
13
+ from folio.frontmatter import front_block, sections, split
14
+ from folio.errors import FolioError
15
+
16
+ from .errors import LabError
17
+ from .lab import Lab
18
+
19
+ _STATUS_LINE = re.compile(r"^status:[^\n]*\n?", re.M)
20
+ _ITEM_ID = re.compile(r"^\s*[-*]\s+([PD]\d+)\b", re.M)
21
+ _YAML_FENCE = re.compile(r"^(```|~~~)\s*ya?ml\s*\n(.*?)^\1\s*$", re.S | re.M)
22
+
23
+ CONFIG_HEADING = "Pinned configuration"
24
+ PREDICTIONS_HEADING = "Predictions"
25
+ RULES_HEADING = "Decision rules"
26
+
27
+
28
+ @dataclass
29
+ class Protocol:
30
+ slug: str
31
+ doc: Document
32
+ path: str # from the lab root
33
+ abspath: Path
34
+
35
+ @property
36
+ def status(self) -> str:
37
+ return self.doc.status
38
+
39
+ def text(self) -> str:
40
+ return self.abspath.read_text(encoding="utf-8")
41
+
42
+
43
+ def find(lab: Lab, slug: str) -> Protocol | None:
44
+ docs = lab.library().find(slug, "protocol")
45
+ if not docs:
46
+ return None
47
+ doc = docs[0]
48
+ main = doc.main
49
+ if main is None:
50
+ return None
51
+ return Protocol(doc.id, doc, lab.doc_path(main.path), main.abspath)
52
+
53
+
54
+ def require(lab: Lab, slug: str) -> Protocol:
55
+ proto = find(lab, slug)
56
+ if proto is None:
57
+ raise LabError(f"no protocol `{slug}` in the library; draft it with folio's write skill first")
58
+ return proto
59
+
60
+
61
+ def hash_text(text: str) -> str:
62
+ """The sha256 of a protocol's text with its front matter's `status` line set aside."""
63
+ span = front_block(text)
64
+ if span is not None:
65
+ start, end = span
66
+ text = text[:start] + _STATUS_LINE.sub("", text[start:end]) + text[end:]
67
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
68
+
69
+
70
+ def _section(text: str, heading: str, where: str) -> str:
71
+ try:
72
+ _, body = split(text, where)
73
+ except FolioError as exc:
74
+ raise LabError(str(exc)) from exc
75
+ for name, content in sections(body):
76
+ if name.strip() == heading:
77
+ return content
78
+ raise LabError(f"{where}: has no `## {heading}` section")
79
+
80
+
81
+ def pinned_config(text: str, where: str) -> Any:
82
+ """The parsed `yaml` block under `## Pinned configuration`."""
83
+ blocks = _YAML_FENCE.findall(_section(text, CONFIG_HEADING, where))
84
+ if len(blocks) != 1:
85
+ raise LabError(f"{where}: `## {CONFIG_HEADING}` must hold exactly one fenced yaml block")
86
+ try:
87
+ return yaml.safe_load(blocks[0][1])
88
+ except yaml.YAMLError as exc:
89
+ raise LabError(f"{where}: the pinned configuration is not valid YAML: {exc}") from exc
90
+
91
+
92
+ def scored_ids(text: str, where: str) -> list[str]:
93
+ """The prediction ids (`P1`...) and decision-rule ids (`D1`...) the protocol names, in order."""
94
+ found: list[str] = []
95
+ for heading, prefix in ((PREDICTIONS_HEADING, "P"), (RULES_HEADING, "D")):
96
+ ids = [i for i in _ITEM_ID.findall(_section(text, heading, where)) if i.startswith(prefix)]
97
+ if not ids:
98
+ raise LabError(f"{where}: `## {heading}` names no {prefix}-id")
99
+ found.extend(ids)
100
+ return found