goldlabel 0.2.1__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.
goldlabel/__init__.py ADDED
@@ -0,0 +1,69 @@
1
+ """Content analysis over a corpus (seeds, keywords or the whole corpus, then an LLM classifier), as a library.
2
+
3
+ Importing reads, writes and networks nothing. Two exported names shadow submodules of the package
4
+ (`goldlabel.cost` is the function, `goldlabel.settings` the object): inside the package and in tests
5
+ import by name, `from goldlabel.cost import cache_key`, `from goldlabel.settings import resolve`.
6
+ """
7
+
8
+ from importlib.metadata import PackageNotFoundError, version
9
+
10
+ from goldlabel._gold_rule import GOLD_RULE_FULL, GOLD_RULE_SHORT, GoldError
11
+ from goldlabel._label import label
12
+ from goldlabel.candidates import (
13
+ Candidates,
14
+ aget_candidates,
15
+ get_candidates,
16
+ read_seeds,
17
+ )
18
+ from goldlabel.classifier import Classifier, CostLimit, Estimate, Predictions
19
+ from goldlabel.cost import Cost, cost
20
+ from goldlabel.evaluation import (
21
+ BandReport,
22
+ EvalLog,
23
+ Evaluation,
24
+ aevaluate,
25
+ band_sample,
26
+ eval_log,
27
+ evaluate,
28
+ )
29
+ from goldlabel.gold import Gold, GoldSet
30
+ from goldlabel.search import PgCorpus, S3Corpus, Table
31
+ from goldlabel.settings import settings
32
+ from goldlabel.taxonomy import Taxonomy
33
+
34
+ try:
35
+ __version__ = version("goldlabel")
36
+ except PackageNotFoundError: # running from a source tree that was never installed
37
+ __version__ = "0+unknown"
38
+
39
+ __all__ = [
40
+ "GOLD_RULE_FULL",
41
+ "GOLD_RULE_SHORT",
42
+ "BandReport",
43
+ "Candidates",
44
+ "Classifier",
45
+ "Cost",
46
+ "CostLimit",
47
+ "Estimate",
48
+ "EvalLog",
49
+ "Evaluation",
50
+ "Gold",
51
+ "GoldError",
52
+ "GoldSet",
53
+ "PgCorpus",
54
+ "Predictions",
55
+ "S3Corpus",
56
+ "Table",
57
+ "Taxonomy",
58
+ "__version__",
59
+ "aevaluate",
60
+ "aget_candidates",
61
+ "band_sample",
62
+ "cost",
63
+ "eval_log",
64
+ "evaluate",
65
+ "get_candidates",
66
+ "label",
67
+ "read_seeds",
68
+ "settings",
69
+ ]
goldlabel/_frame.py ADDED
@@ -0,0 +1,33 @@
1
+ """One place that turns a list of row dicts into a polars or a pandas table, so a result can never export one without the other."""
2
+
3
+
4
+ def _columns(rows: list[dict]) -> dict[str, list]:
5
+ names = list(
6
+ dict.fromkeys(k for r in rows for k in r)
7
+ ) # all keys, first-seen order
8
+ return {name: [r.get(name) for r in rows] for name in names}
9
+
10
+
11
+ def to_polars(rows: list[dict]):
12
+ try:
13
+ import polars
14
+ except ImportError:
15
+ raise ImportError("polars is not installed: pip install polars") from None
16
+ # strict=False lets int and float share a column; the schema is from every row, not the first 100
17
+ return polars.DataFrame(_columns(rows), strict=False)
18
+
19
+
20
+ def to_pandas(rows: list[dict]):
21
+ try:
22
+ import pandas
23
+ except ImportError:
24
+ raise ImportError("pandas is not installed: pip install pandas") from None
25
+ # a column with a missing value stays `object` so it holds None, as polars does (not NaN)
26
+ return pandas.DataFrame(
27
+ {
28
+ name: pandas.Series(values, dtype=object)
29
+ if any(v is None for v in values)
30
+ else values
31
+ for name, values in _columns(rows).items()
32
+ }
33
+ )
@@ -0,0 +1,31 @@
1
+ """The gold rule text (`gold_rule.md`, package data) and the error type that carries it."""
2
+
3
+ from importlib import resources
4
+
5
+ _text = (
6
+ resources.files("goldlabel").joinpath("gold_rule.md").read_text(encoding="utf-8")
7
+ )
8
+ _short, _, _full = _text.partition("## Full")
9
+ GOLD_RULE_SHORT = _short.removeprefix("## Short").strip()
10
+ GOLD_RULE_FULL = _full.strip()
11
+
12
+
13
+ class GoldError(ValueError):
14
+ """An error about gold. `str()` is the message, two newlines and the Short gold rule;
15
+ `.text` is the message alone (for the labelling page's HTTP errors, which omit the rule).
16
+
17
+ The `TypeError` and `RuntimeError` cases raised from `gold.py`/`_label.py` use the two
18
+ subclasses below, so `except TypeError` / `except RuntimeError` keep working.
19
+ """
20
+
21
+ def __init__(self, text: str):
22
+ self.text = text
23
+ super().__init__(f"{text}\n\n{GOLD_RULE_SHORT}")
24
+
25
+
26
+ class GoldTypeError(GoldError, TypeError):
27
+ pass
28
+
29
+
30
+ class GoldRuntimeError(GoldError, RuntimeError):
31
+ pass
goldlabel/_html.py ADDED
@@ -0,0 +1,56 @@
1
+ """Tiny helpers for the notebook reprs. All text is escaped; no colours are set, so the notebook theme shows through."""
2
+
3
+ import html
4
+ from collections.abc import Sequence
5
+
6
+ CAP = 50
7
+ _RULE = "border-bottom:1px solid currentColor"
8
+
9
+
10
+ def esc(value) -> str:
11
+ return html.escape("" if value is None else str(value))
12
+
13
+
14
+ def _cell(value) -> str:
15
+ if isinstance(value, (list, tuple)):
16
+ value = ", ".join(str(v) for v in value)
17
+ return esc(value)
18
+
19
+
20
+ def table(columns: Sequence[str], rows: Sequence[Sequence], cap: int = CAP) -> str:
21
+ """A table of at most `cap` rows, then a "N more" line."""
22
+ head = "".join(
23
+ f'<th style="text-align:left;{_RULE}">{esc(c)}</th>' for c in columns
24
+ )
25
+ body = "".join(
26
+ "<tr>"
27
+ + "".join(f'<td style="vertical-align:top">{_cell(v)}</td>' for v in row)
28
+ + "</tr>"
29
+ for row in rows[:cap]
30
+ )
31
+ more = f"<p>{len(rows) - cap} more rows</p>" if len(rows) > cap else ""
32
+ return f"<table><tr>{head}</tr>{body}</table>{more}"
33
+
34
+
35
+ def bars(values: Sequence[tuple[str, int]]) -> str:
36
+ """One line per (label, count): a bar (shape) and the count as text."""
37
+ top = max([n for _, n in values] + [1])
38
+ lines = "".join(
39
+ f"<div>{esc(name)} "
40
+ f'<span style="display:inline-block;height:0;width:{100 * n / top:.0f}px;'
41
+ f'border-top:0.8em solid currentColor;opacity:0.35"></span> {n}</div>'
42
+ for name, n in values
43
+ )
44
+ return f"<div>{lines}</div>"
45
+
46
+
47
+ def warning(text: str) -> str:
48
+ return f'<p style="font-weight:bold;{_RULE}">WARNING: {esc(text)}</p>'
49
+
50
+
51
+ def details(summary: str, body: str, open: bool = False) -> str:
52
+ return f"<details{' open' if open else ''}><summary>{esc(summary)}</summary>{body}</details>"
53
+
54
+
55
+ def line(text: str) -> str:
56
+ return f"<p>{esc(text)}</p>"
goldlabel/_label.py ADDED
@@ -0,0 +1,329 @@
1
+ """The labelling server: the one place (besides `Gold.import_rows`) where gold labels are written.
2
+
3
+ A label is written only because a person's HTTP request says so, stamped `labelled_by: "goldlabel-ui"`.
4
+ Nothing here calls a model or returns a prediction. The server decides which item is next, so the
5
+ page stays thin. The page is `web/index.html` + `app.js` + `app.css` from the package; the three are served only with
6
+ the token and Host checks like everything else, and the HTML carries the token in its asset URLs.
7
+ """
8
+
9
+ import atexit
10
+ import json
11
+ import os
12
+ import secrets
13
+ import sys
14
+ import threading
15
+ import webbrowser
16
+ from datetime import UTC, datetime
17
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
+ from importlib import resources
19
+ from urllib.parse import parse_qs, urlparse
20
+
21
+ from goldlabel._gold_rule import GOLD_RULE_SHORT, GoldError, GoldRuntimeError
22
+ from goldlabel.gold import SPLITS, Gold, GoldSet
23
+ from goldlabel.taxonomy import OFF_TOPIC, Taxonomy, validate_labels
24
+
25
+ OFF_TOPIC_DESCRIPTION = "the item matches none of the labels above"
26
+ MAX_BODY = 1_000_000
27
+ PAGES = { # the only static paths: URL path -> (file in goldlabel/web, content type)
28
+ "/": ("index.html", "text/html"),
29
+ "/app.js": ("app.js", "text/javascript"),
30
+ "/app.css": ("app.css", "text/css"),
31
+ }
32
+
33
+
34
+ def _alive(pid: int) -> bool:
35
+ if sys.platform == "win32": # os.kill(pid, 0) would terminate the process there
36
+ import ctypes
37
+
38
+ handle = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid)
39
+ if handle:
40
+ ctypes.windll.kernel32.CloseHandle(handle)
41
+ return bool(handle)
42
+ try:
43
+ os.kill(pid, 0)
44
+ except ProcessLookupError:
45
+ return False
46
+ except PermissionError: # it exists, it is somebody else's
47
+ return True
48
+ return True
49
+
50
+
51
+ def _acquire(lock) -> None:
52
+ lock.parent.mkdir(parents=True, exist_ok=True)
53
+ for _ in range(3):
54
+ try:
55
+ fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
56
+ except FileExistsError:
57
+ try:
58
+ info = json.loads(lock.read_text())
59
+ pid, started = int(info["pid"]), info.get("started")
60
+ except (OSError, ValueError, KeyError, TypeError):
61
+ pid, started = None, None # unreadable: stale
62
+ if pid is not None and _alive(pid):
63
+ raise GoldRuntimeError(
64
+ f"{lock.with_suffix('')} is already being labelled (pid {pid}, started "
65
+ f"{started}); finish or stop that session first"
66
+ ) from None
67
+ lock.unlink(missing_ok=True)
68
+ continue
69
+ with os.fdopen(fd, "w") as f:
70
+ json.dump({"pid": os.getpid(), "started": datetime.now(UTC).isoformat()}, f)
71
+ return
72
+ raise GoldRuntimeError(f"could not take the lock {lock}")
73
+
74
+
75
+ def _release(lock) -> None:
76
+ try:
77
+ if json.loads(lock.read_text()).get("pid") == os.getpid():
78
+ lock.unlink(missing_ok=True)
79
+ except (OSError, ValueError, AttributeError):
80
+ pass
81
+
82
+
83
+ class Handle:
84
+ def __init__(self, gold: Gold, taxonomy: Taxonomy, split: str | None):
85
+ self.gold, self.taxonomy, self.split = gold, taxonomy, split
86
+ self.token = secrets.token_urlsafe(32)
87
+ self.lock = gold.path.with_name(gold.path.name + ".lock")
88
+ self._mutex = threading.Lock()
89
+ self._stopping = False
90
+ self._stopped = threading.Event() # set once the lock is released
91
+ self._history: list[str] = []
92
+ self.current: str | None = None
93
+ self.server: ThreadingHTTPServer
94
+
95
+ @property
96
+ def port(self) -> int:
97
+ return self.server.server_address[1]
98
+
99
+ @property
100
+ def url(self) -> str:
101
+ return f"http://127.0.0.1:{self.port}/?token={self.token}"
102
+
103
+ def wait(self) -> None:
104
+ self._stopped.wait()
105
+
106
+ def stop(self) -> None:
107
+ with self._mutex:
108
+ if self._stopping:
109
+ return
110
+ self._stopping = True
111
+ atexit.unregister(self.stop)
112
+ self.server.shutdown()
113
+ self.server.server_close()
114
+ _release(self.lock)
115
+ self._stopped.set()
116
+
117
+ # --- state; the caller holds self._mutex ------------------------------------------------
118
+
119
+ def _scope(self):
120
+ return [r for r in self.gold._read() if self.split in (None, r.split)]
121
+
122
+ def _after(self, rows, id_):
123
+ """The next pending row after `id_` in file order, else the first pending one."""
124
+ ids = [r.id for r in rows]
125
+ start = ids.index(id_) + 1 if id_ in ids else 0
126
+ for r in rows[start:] + rows[:start]:
127
+ if not r.labels:
128
+ return r.id
129
+ return None
130
+
131
+ def _state(self) -> dict:
132
+ rows = self._scope()
133
+ by_id = {r.id: r for r in rows}
134
+ row = by_id.get(self.current) if self.current else None
135
+ done = sum(1 for r in rows if r.labels)
136
+ position = [r.id for r in rows].index(row.id) + 1 if row else len(rows)
137
+ descriptions = [
138
+ (label.name, label.description) for label in self.taxonomy.labels
139
+ ]
140
+ descriptions.append((OFF_TOPIC, OFF_TOPIC_DESCRIPTION))
141
+ return {
142
+ "item": {"id": row.id, "text": row.text, "labels": row.labels or None}
143
+ if row
144
+ else None,
145
+ "position": position,
146
+ "done": done,
147
+ "total": len(rows),
148
+ "labels": [{"name": n, "description": d} for n, d in descriptions],
149
+ "mode": self.taxonomy.mode,
150
+ "counts": GoldSet(rows).counts(self.taxonomy),
151
+ "finished": row is None,
152
+ }
153
+
154
+ def _move(self, rows, after) -> None:
155
+ if self.current is not None:
156
+ self._history.append(self.current)
157
+ self.current = self._after(rows, after)
158
+
159
+ def state(self) -> dict:
160
+ with self._mutex:
161
+ return self._state()
162
+
163
+ def label(self, id_: str, labels: list[str]) -> dict:
164
+ validate_labels(labels, self.taxonomy)
165
+ with self._mutex:
166
+ all_rows = self.gold._read()
167
+ rows = [r for r in all_rows if self.split in (None, r.split)]
168
+ target = next((r for r in all_rows if r.id == id_ and r in rows), None)
169
+ if target is None:
170
+ raise KeyError(id_)
171
+ stamp = {
172
+ "labels": labels,
173
+ "labelled_by": "goldlabel-ui",
174
+ "labelled_at": datetime.now(UTC).isoformat(),
175
+ }
176
+ self.gold._write(
177
+ [r.model_copy(update=stamp) if r is target else r for r in all_rows]
178
+ )
179
+ self._move(self._scope(), id_)
180
+ return self._state()
181
+
182
+ def back(self) -> dict:
183
+ with self._mutex:
184
+ if self._history:
185
+ self.current = self._history.pop()
186
+ return self._state()
187
+
188
+ def skip(self) -> dict:
189
+ with self._mutex:
190
+ if self.current is not None:
191
+ self._move(self._scope(), self.current)
192
+ return self._state()
193
+
194
+
195
+ class _Request(BaseHTTPRequestHandler):
196
+ session: Handle # set on a subclass made per server in `serve`
197
+
198
+ def log_message(self, format, *args): # keep the notebook output clean
199
+ pass
200
+
201
+ def _send(self, status: int, body, content_type="application/json") -> None:
202
+ data = (
203
+ json.dumps(body) if content_type == "application/json" else body
204
+ ).encode()
205
+ self.send_response(status)
206
+ self.send_header("Content-Type", content_type + "; charset=utf-8")
207
+ self.send_header("Content-Length", str(len(data)))
208
+ self.send_header("Cache-Control", "no-store")
209
+ self.end_headers()
210
+ self.wfile.write(data)
211
+
212
+ def _allowed(self, handle: Handle) -> bool:
213
+ # The Host check stops DNS rebinding; the token stops any other local process or page.
214
+ if self.headers.get("Host") not in (
215
+ f"127.0.0.1:{handle.port}",
216
+ f"localhost:{handle.port}",
217
+ ):
218
+ return False
219
+ given = (
220
+ self.headers.get("X-Goldlabel-Token")
221
+ or (parse_qs(urlparse(self.path).query).get("token", [""])[0])
222
+ )
223
+ return secrets.compare_digest(given.encode(), handle.token.encode())
224
+
225
+ def _body(self) -> dict | None:
226
+ try:
227
+ n = int(self.headers.get("Content-Length") or 0)
228
+ body = json.loads(self.rfile.read(n)) if 0 < n <= MAX_BODY else {}
229
+ except ValueError:
230
+ return None
231
+ return body if isinstance(body, dict) else None
232
+
233
+ def do_GET(self):
234
+ handle = self.session
235
+ if not self._allowed(handle):
236
+ return self._send(403, {"error": "forbidden"})
237
+ path = urlparse(self.path).path
238
+ if path in PAGES:
239
+ name, kind = PAGES[path]
240
+ page = resources.files("goldlabel").joinpath("web", name).read_text("utf-8")
241
+ return self._send(200, page.replace("__TOKEN__", handle.token), kind)
242
+ if path == "/api/state":
243
+ return self._send(200, handle.state())
244
+ self._send(404, {"error": "not found"})
245
+
246
+ def do_POST(self):
247
+ handle = self.session
248
+ if not self._allowed(handle):
249
+ return self._send(403, {"error": "forbidden"})
250
+ path = urlparse(self.path).path
251
+ body = self._body()
252
+ if body is None:
253
+ return self._send(400, {"error": "the body must be a JSON object"})
254
+ if path == "/api/label":
255
+ id_, labels = body.get("id"), body.get("labels")
256
+ if not isinstance(id_, str) or not (
257
+ isinstance(labels, list) and all(isinstance(x, str) for x in labels)
258
+ ):
259
+ return self._send(400, {"error": "send {id: str, labels: [str]}"})
260
+ try:
261
+ return self._send(200, handle.label(id_, labels))
262
+ except KeyError:
263
+ return self._send(404, {"error": f"no row {id_!r} in this split"})
264
+ except ValueError as e:
265
+ return self._send(400, {"error": getattr(e, "text", str(e))})
266
+ if path == "/api/back":
267
+ return self._send(200, handle.back())
268
+ if path == "/api/skip":
269
+ return self._send(200, handle.skip())
270
+ if path == "/api/done":
271
+ self._send(200, handle.state())
272
+ threading.Thread(target=handle.stop, daemon=True).start()
273
+ return
274
+ self._send(404, {"error": "not found"})
275
+
276
+
277
+ def serve(gold: Gold, taxonomy: Taxonomy, *, split=None, port=None) -> Handle:
278
+ """Start the labelling server on 127.0.0.1 and return at once; it resumes at the first
279
+ unlabelled row (file order) of `split` (`dev`, `test` or both)."""
280
+ if split is not None and split not in SPLITS:
281
+ raise GoldError(f"split must be 'dev', 'test' or None, not {split!r}")
282
+ handle = Handle(gold, taxonomy, split)
283
+ _acquire(handle.lock)
284
+ try:
285
+ handle.current = handle._after(handle._scope(), "")
286
+ request = type("Request", (_Request,), {"session": handle})
287
+ handle.server = ThreadingHTTPServer(("127.0.0.1", port or 0), request)
288
+ threading.Thread(target=handle.server.serve_forever, daemon=True).start()
289
+ except BaseException:
290
+ _release(handle.lock)
291
+ raise
292
+ atexit.register(handle.stop)
293
+ return handle
294
+
295
+
296
+ def label(
297
+ gold: Gold,
298
+ taxonomy: Taxonomy,
299
+ *,
300
+ split: str | None = None,
301
+ block: bool = True,
302
+ open_browser: bool = True,
303
+ port: int | None = None,
304
+ ):
305
+ """Open the labelling page. Returns the labelled rows after **Done**, or the `Handle` when
306
+ `block=False`. Resumes at the first unlabelled row; every click is saved at once."""
307
+ handle = serve(gold, taxonomy, split=split, port=port)
308
+ try:
309
+ orphans = len(gold.orphaned(taxonomy))
310
+ if orphans:
311
+ print(
312
+ f"{orphans} labelled row(s) in {gold.path} use labels that are not in this "
313
+ "taxonomy; continuing."
314
+ )
315
+ print(
316
+ f"Labelling page: {handle.url}\n"
317
+ "Label the rows in your browser, then press Done on the page.\n"
318
+ f"{GOLD_RULE_SHORT}"
319
+ )
320
+ if open_browser:
321
+ webbrowser.open(handle.url)
322
+ if not block:
323
+ return handle
324
+ handle.wait()
325
+ except BaseException:
326
+ handle.stop()
327
+ raise
328
+ rows = [r for r in gold._read() if r.labels and split in (None, r.split)]
329
+ return GoldSet(rows, split or "all", gold.path)
goldlabel/_sync.py ADDED
@@ -0,0 +1,55 @@
1
+ import asyncio
2
+ import threading
3
+
4
+
5
+ def run(coro):
6
+ """Run a coroutine to completion from sync code, also inside a running event loop."""
7
+ try:
8
+ asyncio.get_running_loop()
9
+ except RuntimeError:
10
+ return asyncio.run(coro)
11
+ box: dict = {}
12
+ done = threading.Event()
13
+
14
+ def work():
15
+ loop = asyncio.new_event_loop()
16
+ asyncio.set_event_loop(loop)
17
+ task = loop.create_task(coro)
18
+ box["loop"], box["task"] = loop, task
19
+ try:
20
+ box["value"] = loop.run_until_complete(task)
21
+ except BaseException as exc: # noqa: BLE001 - re-raised in the caller
22
+ box["error"] = exc
23
+ finally:
24
+ try:
25
+ rest = [t for t in asyncio.all_tasks(loop) if not t.done()]
26
+ for t in rest:
27
+ t.cancel()
28
+ if rest:
29
+ loop.run_until_complete(
30
+ asyncio.gather(*rest, return_exceptions=True)
31
+ )
32
+ loop.run_until_complete(loop.shutdown_asyncgens())
33
+ finally:
34
+ loop.close()
35
+ done.set()
36
+
37
+ thread = threading.Thread(target=work, daemon=True)
38
+ thread.start()
39
+ try:
40
+ done.wait()
41
+ except KeyboardInterrupt:
42
+ while thread.is_alive() and "task" not in box:
43
+ thread.join(0.01)
44
+ if not box["loop"].is_closed():
45
+ try:
46
+ box["loop"].call_soon_threadsafe(box["task"].cancel)
47
+ except RuntimeError: # loop already closed: the work finished
48
+ pass
49
+ done.wait()
50
+ thread.join()
51
+ raise
52
+ thread.join()
53
+ if "error" in box:
54
+ raise box["error"]
55
+ return box["value"]