rulegrid-editor 0.1.0__tar.gz

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,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ *.egg-info/
8
+ .amp/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Félix Martel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.5
2
+ Name: rulegrid-editor
3
+ Version: 0.1.0
4
+ Summary: Browse, edit and try a rulegrid policy in the browser.
5
+ Author: Félix Martel
6
+ Author-email: Grégoire Lothe <51675587+glothe@users.noreply.github.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.13
10
+ Requires-Dist: rulegrid>=0.1.0
11
+ Description-Content-Type: text/markdown
12
+
13
+ # rulegrid-editor
14
+
15
+ Browse, edit and try a [rulegrid](https://github.com/the-marshmallow-project/rulegrid) policy in the browser, with
16
+ one command:
17
+
18
+ ```bash
19
+ uvx rulegrid-editor PROFILE
20
+ ```
21
+
22
+ PROFILE is a profile directory, such as `examples/rbr/profiles/default`. The page mirrors the policy's concepts: its
23
+ profile (context and principles), its structure, its turn and response axes and their values, its tiers and their
24
+ grids of cells, its prohibitions and its lexicons. Every id links to its definition and previews it on hover.
25
+
26
+ - **Document** reads the policy as people do.
27
+ - Each concept's page edits it with forms built from the profile's JSON Schema; a tier's grid opens a cell's form.
28
+ The structure is read-only: edit `structure.yaml` and run `rulegrid generate`.
29
+ - **Changes** compares the policy with git `HEAD`, article by article and line by line.
30
+ - **YAML** edits the files as text.
31
+ - The **Try** panel classifies a turn and a response, and shows the cell they land in, the nudge and the compliance.
32
+
33
+ Edits stay unsaved until **Save** (or Ctrl+S), which writes the files in place and keeps their comments.
34
+ The server binds to loopback and has no authentication; do not expose it without an authenticated proxy.
35
+
36
+ The try panel classifies with `--classifier`, which reads its endpoint and key from the environment, as
37
+ `rulegrid eval` does. OpenRouter serves both built-in classifiers:
38
+
39
+ ```bash
40
+ export RULEGRID_SYSTEMONE_URL=https://openrouter.ai/api RULEGRID_SYSTEMONE_KEY=$OPENROUTER_API_KEY
41
+ rulegrid-editor examples/rbr/profiles/default --classifier systemone:liquid/d1-ultra --threshold alert=0.2
42
+
43
+ export RULEGRID_LLM_URL=https://openrouter.ai/api/v1 RULEGRID_LLM_KEY=$OPENROUTER_API_KEY
44
+ rulegrid-editor examples/three_laws/profiles/default --classifier judge:openai/gpt-5-mini
45
+ ```
46
+
47
+ Without one, the panel takes the values set by hand.
48
+
49
+ The page is [Preact](https://preactjs.com) and [htm](https://github.com/developit/htm), vendored in
50
+ `static/vendor` (7 KB gzipped): there is no build step and nothing to install from npm.
@@ -0,0 +1,38 @@
1
+ # rulegrid-editor
2
+
3
+ Browse, edit and try a [rulegrid](https://github.com/the-marshmallow-project/rulegrid) policy in the browser, with
4
+ one command:
5
+
6
+ ```bash
7
+ uvx rulegrid-editor PROFILE
8
+ ```
9
+
10
+ PROFILE is a profile directory, such as `examples/rbr/profiles/default`. The page mirrors the policy's concepts: its
11
+ profile (context and principles), its structure, its turn and response axes and their values, its tiers and their
12
+ grids of cells, its prohibitions and its lexicons. Every id links to its definition and previews it on hover.
13
+
14
+ - **Document** reads the policy as people do.
15
+ - Each concept's page edits it with forms built from the profile's JSON Schema; a tier's grid opens a cell's form.
16
+ The structure is read-only: edit `structure.yaml` and run `rulegrid generate`.
17
+ - **Changes** compares the policy with git `HEAD`, article by article and line by line.
18
+ - **YAML** edits the files as text.
19
+ - The **Try** panel classifies a turn and a response, and shows the cell they land in, the nudge and the compliance.
20
+
21
+ Edits stay unsaved until **Save** (or Ctrl+S), which writes the files in place and keeps their comments.
22
+ The server binds to loopback and has no authentication; do not expose it without an authenticated proxy.
23
+
24
+ The try panel classifies with `--classifier`, which reads its endpoint and key from the environment, as
25
+ `rulegrid eval` does. OpenRouter serves both built-in classifiers:
26
+
27
+ ```bash
28
+ export RULEGRID_SYSTEMONE_URL=https://openrouter.ai/api RULEGRID_SYSTEMONE_KEY=$OPENROUTER_API_KEY
29
+ rulegrid-editor examples/rbr/profiles/default --classifier systemone:liquid/d1-ultra --threshold alert=0.2
30
+
31
+ export RULEGRID_LLM_URL=https://openrouter.ai/api/v1 RULEGRID_LLM_KEY=$OPENROUTER_API_KEY
32
+ rulegrid-editor examples/three_laws/profiles/default --classifier judge:openai/gpt-5-mini
33
+ ```
34
+
35
+ Without one, the panel takes the values set by hand.
36
+
37
+ The page is [Preact](https://preactjs.com) and [htm](https://github.com/developit/htm), vendored in
38
+ `static/vendor` (7 KB gzipped): there is no build step and nothing to install from npm.
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "rulegrid-editor"
3
+ version = "0.1.0"
4
+ description = "Browse, edit and try a rulegrid policy in the browser."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "Félix Martel" },
10
+ { name = "Grégoire Lothe", email = "51675587+glothe@users.noreply.github.com" },
11
+ ]
12
+ requires-python = ">=3.13"
13
+ dependencies = ["rulegrid>=0.1.0"]
14
+
15
+ [project.scripts]
16
+ rulegrid-editor = "rulegrid_editor:main"
17
+
18
+ [tool.uv.sources]
19
+ rulegrid = { workspace = true }
20
+
21
+ [build-system]
22
+ requires = ["hatchling"]
23
+ build-backend = "hatchling.build"
@@ -0,0 +1,33 @@
1
+ """rulegrid-editor: browse, edit and try a rulegrid policy in the browser.
2
+
3
+ PROFILE is a profile directory, such as `examples/rbr/profiles/default`. The try panel's classifier reads its endpoint
4
+ and key from the environment, as `rulegrid eval` does.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+ from collections.abc import Sequence
12
+
13
+ from rulegrid.cli import PROFILE_HELP, classifier
14
+ from rulegrid.policy import PolicyError
15
+ from rulegrid_editor.server import edit, serve
16
+
17
+ __all__ = ["edit", "main", "serve"]
18
+
19
+
20
+ def main(argv: Sequence[str] | None = None) -> int:
21
+ parser = argparse.ArgumentParser(prog="rulegrid-editor", description=(__doc__ or "").split("\n\n")[0])
22
+ parser.add_argument("profile", metavar="PROFILE", help=PROFILE_HELP)
23
+ parser.add_argument("--port", type=int, default=8765)
24
+ parser.add_argument("--classifier", help="the try panel's: systemone[:MODEL], judge:MODEL or module:attr")
25
+ parser.add_argument("--threshold", action="append", default=[], metavar="TIER=P", help="SystemOne tiers")
26
+ args = parser.parse_args(argv)
27
+ thresholds = {t: float(p) for t, p in (t.split("=") for t in args.threshold)}
28
+ try:
29
+ serve(args.profile, args.port, args.classifier and classifier(args.classifier, thresholds))
30
+ except PolicyError as error:
31
+ print(error, file=sys.stderr)
32
+ return 1
33
+ return 0
@@ -0,0 +1,365 @@
1
+ """The editor's server: a stdlib web server over one profile, and the page in `static/`.
2
+
3
+ Every view runs on the page's unsaved text: the server loads the policy from it, checks it, compares it with git `HEAD`
4
+ and tries a turn on it. Every edit goes through `edit`, which rewrites only what changed, so comments and layout stay.
5
+ Saving writes the files in place, and git is the versioning. It binds to localhost and has no auth.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import difflib
13
+ import json
14
+ import mimetypes
15
+ import os
16
+ import shutil
17
+ import subprocess
18
+ import tempfile
19
+ import threading
20
+ import weakref
21
+ from collections.abc import Callable, Mapping
22
+ from dataclasses import asdict
23
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
24
+ from importlib.resources import files as package_files
25
+ from pathlib import Path
26
+ from typing import Any, Literal
27
+
28
+ import yamlrocks
29
+
30
+ from rulegrid.check import check
31
+ from rulegrid.diff import diff
32
+ from rulegrid.generate import profile_schema
33
+ from rulegrid.lexicon import Entry, match
34
+ from rulegrid.policy import PROFILE, STRUCTURE, Policy, PolicyError, load
35
+ from rulegrid.render import nudge
36
+ from rulegrid.rules import comply, prescribe
37
+ from rulegrid.types import Classification, Classifier, Message
38
+
39
+ Path_ = list[str | int]
40
+
41
+
42
+ def edit(text: str, path: Path_, value: Any) -> str:
43
+ """`text`, a YAML document, with the node at `path` set to `value`, or removed when `value` is None.
44
+
45
+ Only the keys and items that differ are rewritten, so the comments and layout around them stay; a mapping missing
46
+ on the way is created. A list keeps its layout when items are removed or appended, as the page does.
47
+ """
48
+ doc = yamlrocks.loads(text, option=yamlrocks.OPT_ROUND_TRIP)
49
+ parent, old = doc, yamlrocks.loads(text) or {}
50
+ for key in path[:-1]:
51
+ if not _has(old, key) or old[key] is None:
52
+ parent[key], old[key] = {}, {}
53
+ parent, old = parent[key], old[key]
54
+ splices: list[tuple[Path_, list[Any]]] = []
55
+ if not path: # the whole file
56
+ _merge({"": doc}, "", old, value, [], splices)
57
+ elif value is None:
58
+ if _has(old, path[-1]):
59
+ del parent[path[-1]]
60
+ elif _has(old, path[-1]):
61
+ _merge(parent, path[-1], old[path[-1]], value, path, splices)
62
+ else:
63
+ parent[path[-1]] = value
64
+ out = yamlrocks.dumps(doc, option=yamlrocks.OPT_ROUND_TRIP)
65
+ for at, items in splices:
66
+ out = _splice(out, at, items)
67
+ return out.decode()
68
+
69
+
70
+ def _has(node: Any, key: str | int) -> bool:
71
+ return 0 <= key < len(node) if isinstance(node, list) and isinstance(key, int) else key in node
72
+
73
+
74
+ def _merge(parent: Any, key: str | int, old: Any, new: Any, at: Path_, splices: list[tuple[Path_, list[Any]]]) -> None:
75
+ """Set `parent[key]`, which holds `old`, to `new`, descending into what both share.
76
+
77
+ A list that grows by its end, or flow-style, is left to a splice of the dumped text: assigning a whole list would
78
+ re-indent it and drop its comments.
79
+ """
80
+ if old == new:
81
+ return
82
+ if isinstance(old, dict) and isinstance(new, dict):
83
+ node = parent[key]
84
+ for k in [k for k in old if k not in new]:
85
+ del node[k]
86
+ for k, v in new.items():
87
+ if k in old:
88
+ _merge(node, k, old[k], v, [*at, k], splices)
89
+ else:
90
+ node[k] = v
91
+ return
92
+ if not (isinstance(old, list) and isinstance(new, list) and old):
93
+ parent[key] = new
94
+ return
95
+ if parent[key].node.style == "flow":
96
+ splices.append((at, new))
97
+ return
98
+ keys = [json.dumps(x, sort_keys=True) for x in old], [json.dumps(x, sort_keys=True) for x in new]
99
+ ops = difflib.SequenceMatcher(None, *keys, autojunk=False).get_opcodes()
100
+ if all(op in ("equal", "delete") for op, *_ in ops): # items removed: delete them, and their comments stay put
101
+ for op, i1, i2, *_ in reversed(ops):
102
+ for i in reversed(range(i1, i2) if op == "delete" else ()):
103
+ del parent[key][i]
104
+ return
105
+ if len(new) < len(old):
106
+ parent[key] = new
107
+ return
108
+ for i, (a, b) in enumerate(zip(old, new[: len(old)], strict=True)):
109
+ _merge(parent[key], i, a, b, [*at, i], splices)
110
+ if len(new) > len(old):
111
+ splices.append((at, new[len(old) :]))
112
+
113
+
114
+ def _splice(text: bytes, at: Path_, items: list[Any]) -> bytes:
115
+ """`text` with the flow list at `at` set to `items`, or the block list at `at` followed by `items`."""
116
+ node = yamlrocks.loads(text, option=yamlrocks.OPT_ROUND_TRIP)
117
+ for key in at:
118
+ node = node[key]
119
+ if node.node.style == "flow":
120
+ start, end = node.node.offset, text.index(b"]", node.node.end_offset - 1) + 1
121
+ flow = yamlrocks.dumps(items, option=yamlrocks.OPT_FLOW_STYLE).rstrip()
122
+ return text[:start] + flow + text[end:]
123
+ last = node[len(node.unwrap()) - 1].node
124
+ indent = " " * (last.column - 3) # the column of the item's content, past its "- "
125
+ end = text.find(b"\n", last.end_offset) + 1 or len(text)
126
+ block = "".join(indent + line for line in yamlrocks.dumps(items).decode().splitlines(keepends=True))
127
+ return text[:end] + block.encode() + text[end:]
128
+
129
+
130
+ class Workspace:
131
+ """The policy's files on disk, and the policy they make with the page's unsaved text over them.
132
+
133
+ Unsaved text is loaded from a mirror of the policy's folder, which the lock guards: a `Policy` reads its templates
134
+ and generated files from its folder.
135
+ """
136
+
137
+ def __init__(self, path: Path, classifier: Callable[[Policy], Classifier] | None):
138
+ policy = load(path)
139
+ profile_dir = policy.profile_dir.resolve()
140
+ self.base = Path(os.path.commonpath([policy.root, profile_dir]))
141
+ self.profile = profile_dir.relative_to(self.base)
142
+ names = [policy.root / STRUCTURE, profile_dir / PROFILE, *sorted(profile_dir.glob("lexicons/*.yaml"))]
143
+ self.names = [str(p.relative_to(self.base)) for p in names]
144
+ self.classifier = classifier
145
+ directory = tempfile.TemporaryDirectory(prefix="rulegrid-editor-")
146
+ self.mirror = Path(directory.name) / self.base.name
147
+ weakref.finalize(self, directory.cleanup)
148
+ self.lock = threading.Lock()
149
+ self.head: tuple[str, Policy, dict[str, str]] | None = None
150
+
151
+ def files(self) -> dict[str, str]:
152
+ return {name: (self.base / name).read_text() for name in self.names}
153
+
154
+ def _texts(self, texts: Mapping[str, str]) -> dict[str, str]:
155
+ if unknown := [name for name in texts if name not in self.names]:
156
+ raise ValueError(f"not a file of this policy: {', '.join(unknown)}")
157
+ return dict(texts)
158
+
159
+ def save(self, texts: Mapping[str, str]) -> None:
160
+ with self.lock:
161
+ for name, text in self._texts(texts).items():
162
+ (self.base / name).write_text(text)
163
+
164
+ def load(self, texts: Mapping[str, str]) -> Policy:
165
+ """The policy with `texts` over the files on disk. Call it with the lock held."""
166
+ texts = self._texts(texts)
167
+ shutil.copytree(self.base, self.mirror, dirs_exist_ok=True, ignore=shutil.ignore_patterns(".*", "__pycache__"))
168
+ for name, text in texts.items():
169
+ (self.mirror / name).write_text(text)
170
+ return load(self.mirror / self.profile)
171
+
172
+ def changes(self, policy: Policy, texts: Mapping[str, str]) -> dict[str, Any]:
173
+ """What changed from git `HEAD`: the articles, and each file's lines."""
174
+ git = ["git", "-C", str(self.base)]
175
+ try:
176
+ sha = subprocess.check_output([*git, "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL).strip()
177
+ if self.head is None or self.head[0] != sha:
178
+ show = [*git, "show"]
179
+ old = {n: subprocess.check_output([*show, f"{sha}:./{n}"], text=True) for n in self.names}
180
+ self.head = sha, load(self.base / self.profile, revision=sha), old
181
+ except (subprocess.CalledProcessError, OSError, PolicyError):
182
+ return {"error": "no version at git HEAD to compare with"}
183
+ _, before, old = self.head
184
+ lines = {
185
+ n: list(difflib.unified_diff(old[n].splitlines(), t.splitlines(), "HEAD", "now", lineterm=""))[2:]
186
+ for n, t in texts.items()
187
+ }
188
+ return {"articles": str(diff(before, policy)), "files": {n: ls for n, ls in lines.items() if ls}}
189
+
190
+ def view(self, texts: Mapping[str, str]) -> dict[str, Any]:
191
+ with self.lock:
192
+ try:
193
+ policy = self.load(texts)
194
+ except PolicyError as error:
195
+ return {"problems": [str(p) for p in error.problems]}
196
+ current = {**self.files(), **texts}
197
+ return {
198
+ "problems": [str(p) for p in check(policy)],
199
+ "policy": _outline(policy),
200
+ "changes": self.changes(policy, current),
201
+ "schemas": _schemas(policy),
202
+ "data": {name: yamlrocks.loads(text) for name, text in current.items()},
203
+ }
204
+
205
+ def edit(self, texts: Mapping[str, str], file: str, path: Path_, value: Any) -> str:
206
+ self._texts({file: ""})
207
+ with self.lock:
208
+ return edit(texts[file] if file in texts else (self.base / file).read_text(), path, value)
209
+
210
+ def attempt(self, texts: Mapping[str, str], request: dict[str, Any]) -> dict[str, Any]:
211
+ """Read a turn and maybe a response: classified from their text, or from the values the page sets."""
212
+ turn_text, response_text = request.get("turn", ""), request.get("response", "")
213
+ with self.lock:
214
+ policy = self.load(texts)
215
+ classifier = self.classifier(policy) if self.classifier else None
216
+ hits = match(policy.lexicons, turn_text)
217
+ convo = [Message("user", turn_text)]
218
+
219
+ async def read(of: Literal["turn", "response"], values: dict[str, Any] | None) -> Classification:
220
+ if values is not None:
221
+ return Classification({k: frozenset(v) if isinstance(v, list) else v for k, v in values.items()})
222
+ if classifier is None:
223
+ raise ValueError("no classifier: set the values, or start the editor with --classifier")
224
+ if not turn_text.strip():
225
+ raise ValueError("write a turn to classify")
226
+ if of == "turn":
227
+ return await classifier.classify(convo, "turn", hits)
228
+ return await classifier.classify([*convo, Message("assistant", response_text)], "response")
229
+
230
+ async def both() -> tuple[Classification, Classification | None]:
231
+ turn = read("turn", request.get("turn_values"))
232
+ if request.get("response_values") is None and not response_text:
233
+ return await turn, None
234
+ return await asyncio.gather(turn, read("response", request.get("response_values")))
235
+
236
+ turn, response = asyncio.run(both())
237
+ # Values set by hand can leave axes unset: the page shows them, and the compliance once every axis is set.
238
+ missing = [a for a in policy.turn_axes if a not in turn.values]
239
+ complete = response is not None and all(a in response.values for a in policy.response_axes)
240
+ with self.lock:
241
+ text = None if missing else nudge(policy, turn, hits)
242
+ prescription = None if missing else prescribe(policy, turn)
243
+ return {
244
+ "turn": _classification(turn),
245
+ "missing": missing,
246
+ "hits": [{"text": h.text, "meaning": h.entry.meaning, "reminds": h.reminds} for h in hits],
247
+ "prescription": prescription and asdict(prescription),
248
+ "nudge": text,
249
+ "response": response and _classification(response),
250
+ "compliance": response and complete and not missing and asdict(comply(policy, turn, response)),
251
+ }
252
+
253
+
254
+ def _schemas(policy: Policy) -> dict[str, Any]:
255
+ """The JSON Schemas the page builds its forms from: the profile's, and a lexicon file's."""
256
+ lexicon = {
257
+ "type": "object",
258
+ "properties": {
259
+ "reminds": {"title": "Reminds", "enum": list(policy.prohibitions)},
260
+ "entries": {"title": "Entries", "type": "array", "items": Entry.model_json_schema()},
261
+ },
262
+ "required": ["reminds", "entries"],
263
+ }
264
+ return {"profile": profile_schema(policy.structure), "lexicon": lexicon}
265
+
266
+
267
+ def _classification(c: Classification) -> dict[str, Any]:
268
+ values = {k: sorted(v) if isinstance(v, frozenset) else v for k, v in c.values.items()}
269
+ return {**asdict(c), "values": values}
270
+
271
+
272
+ def _json(value: Any) -> Any:
273
+ """The policy's dataclasses as objects, sets and tuples as lists."""
274
+ return list(value) if isinstance(value, set | frozenset | tuple) else asdict(value)
275
+
276
+
277
+ def _outline(policy: Policy) -> dict[str, Any]:
278
+ """The policy as JSON: everything the page draws, from the document to the grids and the previews of every id."""
279
+ return {
280
+ "name": f"{policy.root.name} · {policy.name}",
281
+ "context": policy.context,
282
+ "principles": list(policy.principles.values()),
283
+ "turn_axes": list(policy.turn_axes.values()),
284
+ "response_axes": list(policy.response_axes.values()),
285
+ "tiers": [
286
+ {
287
+ "id": t.id,
288
+ "label": t.label,
289
+ "definition": t.definition,
290
+ "instruction": t.instruction,
291
+ "condition": t.condition,
292
+ "cells": {c.id: {"allowed": c.allowed, "examples": c.examples} for c in t.cells.values()},
293
+ }
294
+ for t in policy.tiers.values()
295
+ ],
296
+ "prohibitions": list(policy.prohibitions.values()),
297
+ "lexicons": [{"name": x.name, "reminds": x.reminds, "size": len(x.entries)} for x in policy.lexicons],
298
+ }
299
+
300
+
301
+ def _handler(workspace: Workspace) -> type[BaseHTTPRequestHandler]:
302
+ static = package_files("rulegrid_editor").joinpath("static")
303
+
304
+ class Handler(BaseHTTPRequestHandler):
305
+ def _send(self, status: int, body: bytes, kind: str = "application/json") -> None:
306
+ self.send_response(status)
307
+ self.send_header("Content-Type", kind)
308
+ self.send_header("Content-Length", str(len(body)))
309
+ self.send_header("Cache-Control", "no-cache")
310
+ self.end_headers()
311
+ self.wfile.write(body)
312
+
313
+ def do_GET(self) -> None:
314
+ if self.path == "/api/files":
315
+ body = {"files": workspace.files(), "classifier": workspace.classifier is not None}
316
+ return self._send(200, json.dumps(body).encode())
317
+ name = "index.html" if self.path == "/" else self.path.removeprefix("/static/")
318
+ file = static.joinpath(*name.split("/"))
319
+ if ".." in name or not file.is_file():
320
+ return self._send(404, b"{}")
321
+ kind = mimetypes.guess_type(name)[0] or "application/octet-stream"
322
+ self._send(200, file.read_bytes(), f"{kind}; charset=utf-8")
323
+
324
+ def do_POST(self) -> None:
325
+ # A cross-origin form or no-CORS fetch cannot send application/json without a preflight.
326
+ if self.headers.get_content_type() != "application/json":
327
+ return self._send(415, b'{"error": "Expected application/json"}')
328
+ try:
329
+ request = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
330
+ texts = request.pop("files", {})
331
+ routes: dict[str, Callable[[], Any]] = {
332
+ "/api/view": lambda: workspace.view(texts),
333
+ "/api/save": lambda: workspace.save(texts),
334
+ "/api/edit": lambda: {"text": workspace.edit(texts, **request)},
335
+ "/api/try": lambda: workspace.attempt(texts, request),
336
+ }
337
+ if self.path not in routes:
338
+ return self._send(404, b"{}")
339
+ self._send(200, json.dumps(routes[self.path](), default=_json).encode())
340
+ except Exception as error: # the page shows any failure, such as a classifier's, next to what caused it
341
+ self._send(400, json.dumps({"error": f"{type(error).__name__}: {error}"}).encode())
342
+
343
+ def log_message(self, format: str, *args: Any) -> None:
344
+ pass
345
+
346
+ return Handler
347
+
348
+
349
+ def serve(path: str | Path, port: int = 8765, classifier: Callable[[Policy], Classifier] | None = None) -> None:
350
+ """Serve the editor for the profile at `path` on `http://127.0.0.1:{port}`, until interrupted.
351
+
352
+ Args:
353
+ path: a profile directory, such as `examples/rbr/profiles/default`, its `profile.yaml`, or a policy directory
354
+ with a single profile.
355
+ port: the local port.
356
+ classifier: makes the try panel's classifier from the policy being edited. Without it, the try panel reads
357
+ the values set by hand.
358
+
359
+ Raises:
360
+ PolicyError: when the profile does not load.
361
+ """
362
+ with ThreadingHTTPServer(("127.0.0.1", port), _handler(Workspace(Path(path), classifier))) as server:
363
+ print(f"rulegrid editor on http://127.0.0.1:{server.server_port}", flush=True)
364
+ with contextlib.suppress(KeyboardInterrupt):
365
+ server.serve_forever()