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 +69 -0
- goldlabel/_frame.py +33 -0
- goldlabel/_gold_rule.py +31 -0
- goldlabel/_html.py +56 -0
- goldlabel/_label.py +329 -0
- goldlabel/_sync.py +55 -0
- goldlabel/candidates.py +467 -0
- goldlabel/classifier.py +516 -0
- goldlabel/cost.py +212 -0
- goldlabel/evaluation.py +356 -0
- goldlabel/gold.py +230 -0
- goldlabel/gold_rule.md +10 -0
- goldlabel/metrics.py +84 -0
- goldlabel/search.py +970 -0
- goldlabel/settings.py +17 -0
- goldlabel/taxonomy.py +74 -0
- goldlabel/web/README.md +17 -0
- goldlabel/web/app.css +1 -0
- goldlabel/web/app.js +182 -0
- goldlabel/web/index.html +16 -0
- goldlabel/web/input.css +1 -0
- goldlabel-0.2.1.dist-info/METADATA +339 -0
- goldlabel-0.2.1.dist-info/RECORD +24 -0
- goldlabel-0.2.1.dist-info/WHEEL +4 -0
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
|
+
)
|
goldlabel/_gold_rule.py
ADDED
|
@@ -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"]
|