quillbench 1.4.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.
quillbench/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """QuillBench.
2
+
3
+ A config-driven human-annotation platform for research datasets. The software lives
4
+ in its own folder; every paper keeps its annotation data in its own folder (a
5
+ *workspace*), described by a ``quillbench_workspace.yaml`` manifest that the studio reads.
6
+ """
7
+
8
+ __version__ = "1.4.1"
9
+ NAME = "QuillBench"
10
+ SHORT_NAME = "QuillBench"
11
+ AUTHOR = "A.C. Pogaku"
quillbench/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
quillbench/accounts.py ADDED
@@ -0,0 +1,344 @@
1
+ """
2
+ Accounts, assignments and session persistence.
3
+
4
+ Two files, two owners:
5
+
6
+ * **Identities** -- who a person is -- live once, in the studio home
7
+ (``accounts.json``, mode 600)::
8
+
9
+ {"users": {"alice": {"password_hash": "pbkdf2_sha256$...", "role": "annotator",
10
+ "display_name": "Alice", "active": true}}}
11
+
12
+ Passwords are stored as salted PBKDF2-SHA256 hashes (standard library, no
13
+ extra dependency).
14
+
15
+ * **Assignments** -- what a person works on in a paper -- live in that paper's
16
+ workspace (``quillbench_assignments.json``), so they travel with the paper's data and
17
+ contain no secrets::
18
+
19
+ {"alice": {"batches": ["demo_001", "demo_002"]}}
20
+
21
+ Session persistence uses an HMAC-signed cookie rather than session state alone:
22
+ annotators work over flaky mobile and SSH-tunnelled connections, and every
23
+ WebSocket reconnect wipes Streamlit's ``session_state``. The cookie carries
24
+ ``name:issued:token`` where ``token`` is an HMAC of the name, the issue time and
25
+ the user's ``session_epoch`` under a server-side secret, so it cannot be forged
26
+ client-side. The server rejects a cookie older than the session lifetime, and
27
+ bumping ``session_epoch`` (password change, disabling, signing out) cancels every
28
+ cookie already handed out. It is a cookie rather than a URL parameter because
29
+ URLs leak through history, screen-sharing and proxy logs.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import hashlib
35
+ import hmac
36
+ import json
37
+ import os
38
+ import secrets
39
+ import tempfile
40
+ import threading
41
+ import time
42
+ from pathlib import Path
43
+
44
+ from . import ldap_auth, paths
45
+ from .config import WorkspaceConfig
46
+
47
+ ROLES = ("annotator", "admin")
48
+ _ITERATIONS = 310_000
49
+
50
+
51
+ # ──────────────────────────────────────────────────────────────────────────
52
+ # Password hashing
53
+ # ──────────────────────────────────────────────────────────────────────────
54
+ def hash_password(password: str, *, iterations: int = _ITERATIONS) -> str:
55
+ salt = secrets.token_hex(16)
56
+ digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), iterations).hex()
57
+ return f"pbkdf2_sha256${iterations}${salt}${digest}"
58
+
59
+
60
+ def verify_password(password: str, stored: str) -> bool:
61
+ try:
62
+ algo, iterations, salt, digest = stored.split("$", 3)
63
+ except ValueError:
64
+ return False
65
+ if algo != "pbkdf2_sha256":
66
+ return False
67
+ check = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), int(iterations)).hex()
68
+ return hmac.compare_digest(check, digest)
69
+
70
+
71
+ # ──────────────────────────────────────────────────────────────────────────
72
+ # JSON files written atomically with private permissions
73
+ # ──────────────────────────────────────────────────────────────────────────
74
+ def _read_json(path: Path) -> dict:
75
+ if not path.exists():
76
+ return {}
77
+ try:
78
+ data = json.loads(path.read_text(encoding="utf-8"))
79
+ except json.JSONDecodeError:
80
+ return {}
81
+ return data if isinstance(data, dict) else {}
82
+
83
+
84
+ def write_json_atomic(path: Path, data: dict, mode: int = 0o600) -> None:
85
+ path.parent.mkdir(parents=True, exist_ok=True)
86
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
87
+ try:
88
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
89
+ json.dump(data, fh, indent=2, ensure_ascii=False)
90
+ os.chmod(tmp, mode)
91
+ os.replace(tmp, path)
92
+ except BaseException:
93
+ Path(tmp).unlink(missing_ok=True)
94
+ raise
95
+
96
+
97
+ # ──────────────────────────────────────────────────────────────────────────
98
+ # Identities
99
+ # ──────────────────────────────────────────────────────────────────────────
100
+ def load_users(path: Path | None = None) -> dict:
101
+ return _read_json(path or paths.accounts_file()).get("users", {})
102
+
103
+
104
+ def save_users(users: dict, path: Path | None = None) -> None:
105
+ write_json_atomic(path or paths.accounts_file(), {"users": users})
106
+
107
+
108
+ def add_user(name: str, password: str, role: str = "annotator", display_name: str = "",
109
+ path: Path | None = None) -> None:
110
+ name = name.strip()
111
+ if not name or ":" in name or "/" in name or "__" in name:
112
+ raise ValueError("A user id must be non-empty and contain no ':', '/' or '__'.")
113
+ if role not in ROLES:
114
+ raise ValueError(f"role must be one of {ROLES}")
115
+ if len(password) < 8:
116
+ raise ValueError("Use a password of at least 8 characters.")
117
+ users = load_users(path)
118
+ if name in users:
119
+ raise ValueError(f"User '{name}' already exists.")
120
+ users[name] = {"password_hash": hash_password(password), "role": role,
121
+ "display_name": display_name or name, "active": True}
122
+ save_users(users, path)
123
+
124
+
125
+ def create_first_admin(name: str, password: str, display_name: str = "") -> None:
126
+ """Make the first account an admin. Only ever works while no account exists."""
127
+ claim = paths.studio_home() / "first_admin.claim"
128
+ try:
129
+ fd = os.open(claim, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
130
+ except FileExistsError:
131
+ raise ValueError("Setup is already being completed.") from None
132
+ os.close(fd)
133
+ try:
134
+ if load_users():
135
+ raise ValueError("An admin already exists.")
136
+ add_user(name, password, "admin", display_name)
137
+ finally:
138
+ claim.unlink(missing_ok=True)
139
+
140
+
141
+ def set_password(name: str, password: str, path: Path | None = None) -> None:
142
+ if len(password) < 8:
143
+ raise ValueError("Use a password of at least 8 characters.")
144
+ users = load_users(path)
145
+ if name not in users:
146
+ raise ValueError(f"No user '{name}'.")
147
+ users[name]["password_hash"] = hash_password(password)
148
+ users[name]["session_epoch"] = int(users[name].get("session_epoch", 0)) + 1
149
+ save_users(users, path)
150
+
151
+
152
+ def update_user(name: str, path: Path | None = None, **fields) -> None:
153
+ users = load_users(path)
154
+ if name not in users:
155
+ raise ValueError(f"No user '{name}'.")
156
+ if "role" in fields and fields["role"] not in ROLES:
157
+ raise ValueError(f"role must be one of {ROLES}")
158
+ was_active = users[name].get("active", True)
159
+ users[name].update(fields)
160
+ if was_active and not users[name].get("active", True):
161
+ users[name]["session_epoch"] = int(users[name].get("session_epoch", 0)) + 1
162
+ save_users(users, path)
163
+
164
+
165
+ def revoke_sessions(name: str, path: Path | None = None) -> None:
166
+ """Cancel every sign-in cookie this person holds, on every device."""
167
+ users = load_users(path)
168
+ if name in users:
169
+ users[name]["session_epoch"] = int(users[name].get("session_epoch", 0)) + 1
170
+ save_users(users, path)
171
+
172
+
173
+ # Verified against when the user id is unknown, so a wrong id takes as long as a
174
+ # wrong password and response time does not reveal which ids exist.
175
+ _DUMMY_HASH = hash_password(secrets.token_hex(16))
176
+
177
+
178
+ def check_login(name: str, password: str, users: dict) -> bool:
179
+ record = users.get(name)
180
+ if not isinstance(record, dict):
181
+ verify_password(password, _DUMMY_HASH)
182
+ return False
183
+ ok = verify_password(password, str(record.get("password_hash", "")))
184
+ return ok and record.get("active", True)
185
+
186
+
187
+ def sign_in(name: str, password: str, users: dict, ldap: dict | None = None) -> bool:
188
+ """Check a sign-in against QuillBench's own accounts, then LDAP when it is set up.
189
+
190
+ An account with its own password always uses it, so LDAP can never take over a
191
+ local account. A person LDAP accepts for the first time gets an annotator
192
+ account (no password stored) when ``auto_create`` is on, or must already have
193
+ been added by an admin.
194
+ """
195
+ record = users.get(name)
196
+ if not ldap_auth.enabled(ldap) or (isinstance(record, dict) and record.get("password_hash")):
197
+ return check_login(name, password, users)
198
+ if isinstance(record, dict) and not record.get("active", True):
199
+ return False
200
+ if record is None and not ldap.get("auto_create", True):
201
+ verify_password(password, _DUMMY_HASH)
202
+ return False
203
+ if not ldap_auth.check(name, password, ldap):
204
+ return False
205
+ if record is None:
206
+ users = load_users()
207
+ if name not in users:
208
+ users[name] = {"role": "annotator", "display_name": name, "active": True, "source": "ldap"}
209
+ save_users(users)
210
+ return True
211
+
212
+
213
+ # ──────────────────────────────────────────────────────────────────────────
214
+ # Sign-in throttling (in-process: the studio runs as one server process)
215
+ # ──────────────────────────────────────────────────────────────────────────
216
+ MAX_FAILURES = 5
217
+ LOCKOUT_SECONDS = 15 * 60
218
+ _failures: dict[str, list[float]] = {}
219
+ _failures_lock = threading.Lock()
220
+
221
+
222
+ def _recent_failures(name: str, now: float) -> list[float]:
223
+ recent = [t for t in _failures.get(name, []) if now - t < LOCKOUT_SECONDS]
224
+ if recent:
225
+ _failures[name] = recent
226
+ else:
227
+ _failures.pop(name, None)
228
+ return recent
229
+
230
+
231
+ def login_locked(name: str) -> bool:
232
+ """True after MAX_FAILURES wrong passwords for this id in the last LOCKOUT_SECONDS."""
233
+ with _failures_lock:
234
+ return len(_recent_failures(name, time.time())) >= MAX_FAILURES
235
+
236
+
237
+ def record_login(name: str, ok: bool) -> None:
238
+ with _failures_lock:
239
+ if ok:
240
+ _failures.pop(name, None)
241
+ else:
242
+ _recent_failures(name, time.time())
243
+ _failures.setdefault(name, []).append(time.time())
244
+
245
+
246
+ def session_epoch(name: str, users: dict) -> int:
247
+ return int(users.get(name, {}).get("session_epoch", 0))
248
+
249
+
250
+ def is_admin(name: str, users: dict) -> bool:
251
+ return users.get(name, {}).get("role", "") == "admin"
252
+
253
+
254
+ # ──────────────────────────────────────────────────────────────────────────
255
+ # Per-workspace assignments
256
+ # ──────────────────────────────────────────────────────────────────────────
257
+ def load_assignments(ws: WorkspaceConfig) -> dict:
258
+ return _read_json(ws.assignments_file)
259
+
260
+
261
+ def save_assignments(ws: WorkspaceConfig, data: dict) -> None:
262
+ write_json_atomic(ws.assignments_file, data, mode=0o640)
263
+
264
+
265
+ def assigned_batches(ws: WorkspaceConfig, name: str, assignments: dict | None = None) -> list[str]:
266
+ assignments = load_assignments(ws) if assignments is None else assignments
267
+ batches = assignments.get(name, {}).get("batches", [])
268
+ return [str(b) for b in batches] if isinstance(batches, list) else []
269
+
270
+
271
+ def legacy_view(ws: WorkspaceConfig, users: dict, assignments: dict | None = None) -> dict:
272
+ """``{name: {"role": ..., "batches": [...]}}`` for one workspace.
273
+
274
+ The combined shape the query-review tasks use (role and batches in one
275
+ record); views that need both -- e.g. the adjudicator's live peer labels --
276
+ read this.
277
+ """
278
+ assignments = load_assignments(ws) if assignments is None else assignments
279
+ return {name: {"role": users.get(name, {}).get("role", "annotator"),
280
+ "batches": assigned_batches(ws, name, assignments)}
281
+ for name in assignments}
282
+
283
+
284
+ # ──────────────────────────────────────────────────────────────────────────
285
+ # Signed session tokens
286
+ # ──────────────────────────────────────────────────────────────────────────
287
+ def app_secret(path: Path | None = None) -> str:
288
+ path = path or paths.secret_file()
289
+ if not path.exists():
290
+ path.parent.mkdir(parents=True, exist_ok=True)
291
+ path.write_text(secrets.token_hex(32), encoding="utf-8")
292
+ try:
293
+ path.chmod(0o600)
294
+ except OSError:
295
+ pass
296
+ return path.read_text(encoding="utf-8").strip()
297
+
298
+
299
+ def make_token(name: str, issued: int, epoch: int, secret: str | None = None) -> str:
300
+ secret = secret or app_secret()
301
+ msg = f"{name}:{issued}:{epoch}".encode()
302
+ return hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:32]
303
+
304
+
305
+ def cookie_value(name: str, issued: int, users: dict, secret: str | None = None) -> str:
306
+ return f"{name}:{issued}:{make_token(name, issued, session_epoch(name, users), secret)}"
307
+
308
+
309
+ def session_from_cookie(raw: str, users: dict, secret: str | None = None,
310
+ max_age: int = 30 * 24 * 3600) -> tuple[str, int] | None:
311
+ """Return ``(user, issued)`` for a valid, unexpired cookie value, else None.
312
+
313
+ DECODE FIRST, THEN SPLIT: the cookie is written through JS
314
+ ``encodeURIComponent()``, which encodes the ':' separators as '%3A', so a
315
+ check against the raw value would reject every valid cookie. ``rsplit``
316
+ because the issue time and token can never contain ':', while a user id is
317
+ parsed unambiguously from the right.
318
+ """
319
+ from urllib.parse import unquote
320
+
321
+ if not raw:
322
+ return None
323
+ parts = unquote(raw).rsplit(":", 2)
324
+ if len(parts) != 3:
325
+ return None
326
+ name, issued_s, token = parts
327
+ try:
328
+ issued = int(issued_s)
329
+ except ValueError:
330
+ return None
331
+ if not 0 <= time.time() - issued <= max_age:
332
+ return None
333
+ record = users.get(name)
334
+ if not record or not record.get("active", True):
335
+ return None
336
+ if hmac.compare_digest(token, make_token(name, issued, session_epoch(name, users), secret)):
337
+ return name, issued
338
+ return None
339
+
340
+
341
+ def name_from_cookie(raw: str, users: dict, secret: str | None = None,
342
+ max_age: int = 30 * 24 * 3600) -> str | None:
343
+ found = session_from_cookie(raw, users, secret, max_age)
344
+ return found[0] if found else None
quillbench/backup.py ADDED
@@ -0,0 +1,48 @@
1
+ """
2
+ Backups: every workspace's answers and assignments, plus the studio home.
3
+
4
+ One ``.tar.gz`` per run in ``settings.backup_dir`` (mode 600), the newest
5
+ ``backup_keep`` kept. Batch and benchmark files are not included -- they are
6
+ inputs that already live in the papers' own folders and version control; the
7
+ answers are what cannot be recreated.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import tarfile
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+
17
+ from . import paths, registry
18
+ from .settings import load_settings
19
+
20
+
21
+ def run(dest: Path | None = None, keep: int | None = None) -> Path:
22
+ settings = load_settings()
23
+ dest = Path(dest or settings.backup_dir)
24
+ keep = settings.backup_keep if keep is None else keep
25
+ dest.mkdir(parents=True, exist_ok=True)
26
+ stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
27
+ target = dest / f"quillbench_backup_{stamp}.tar.gz"
28
+ workspaces, _ = registry.load_all(settings)
29
+ tmp = target.with_suffix(".tmp")
30
+ with tarfile.open(tmp, "w:gz") as tar:
31
+ tar.add(paths.studio_home(), arcname="studio_home")
32
+ for ws in workspaces:
33
+ base = f"workspaces/{ws.id}"
34
+ tar.add(ws.manifest, arcname=f"{base}/{ws.manifest.name}")
35
+ if ws.assignments_file.exists():
36
+ tar.add(ws.assignments_file, arcname=f"{base}/{ws.assignments_file.name}")
37
+ for i, d in enumerate(ws.all_progress_dirs):
38
+ if d.exists():
39
+ tar.add(d, arcname=f"{base}/progress_{i}_{d.parent.name}")
40
+ tmp.chmod(0o600)
41
+ tmp.rename(target)
42
+ digest = hashlib.sha256(target.read_bytes()).hexdigest()
43
+ target.with_name(target.name + ".sha256").write_text(f"{digest} {target.name}\n")
44
+ old = sorted(dest.glob("quillbench_backup_*.tar.gz")) + sorted(dest.glob("labelwise_backup_*.tar.gz"))
45
+ for stale in old[:-keep] if keep > 0 else []:
46
+ stale.unlink(missing_ok=True)
47
+ stale.with_name(stale.name + ".sha256").unlink(missing_ok=True)
48
+ return target
quillbench/builder.py ADDED
@@ -0,0 +1,190 @@
1
+ """Create projects from the web interface.
2
+
3
+ Each project made here is its own workspace folder under the studio home, so it
4
+ never touches a workspace that already exists: nothing here edits another
5
+ paper's manifest, batches, progress or assignments.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import shutil
12
+ from pathlib import Path
13
+
14
+ import pandas as pd
15
+ import yaml
16
+
17
+ from . import accounts, paths, registry
18
+ from .config import MANIFEST_NAME, ConfigError, WorkspaceConfig, load_workspace
19
+
20
+ BATCH_PREFIX = "batch_"
21
+ TASKS = {"choice": "Label buttons", "rating": "Slider rating", "text": "Short written answer"}
22
+
23
+
24
+ def projects_root() -> Path:
25
+ return paths.studio_home() / "projects"
26
+
27
+
28
+ def is_app_project(ws: WorkspaceConfig) -> bool:
29
+ return projects_root().resolve() in ws.root.parents
30
+
31
+
32
+ def clean_labels(text: str) -> list[str]:
33
+ """One label per comma or line, duplicates dropped, order kept."""
34
+ return list(dict.fromkeys(s.strip() for s in re.split(r"[,\n]", text) if s.strip()))
35
+
36
+
37
+ def read_table(source) -> pd.DataFrame:
38
+ df = pd.read_csv(source, dtype=str, keep_default_na=False)
39
+ if df.empty:
40
+ raise ValueError("The CSV has no rows.")
41
+ return df
42
+
43
+
44
+ def with_ids(df: pd.DataFrame, id_column: str | None, start: int = 1) -> pd.DataFrame:
45
+ """Give the table the ``id`` column batches need, unique within it."""
46
+ df = df.copy()
47
+ if id_column and id_column != "id":
48
+ df = df.drop(columns=["id"], errors="ignore").rename(columns={id_column: "id"})
49
+ elif not id_column:
50
+ df = df.drop(columns=["id"], errors="ignore")
51
+ df.insert(0, "id", [str(i) for i in range(start, start + len(df))])
52
+ if df["id"].astype(str).str.strip().eq("").any():
53
+ raise ValueError("Some rows have an empty id.")
54
+ repeats = df["id"][df["id"].duplicated()].unique().tolist()
55
+ if repeats:
56
+ raise ValueError(f"These ids appear more than once: {repeats[:5]}")
57
+ return df
58
+
59
+
60
+ def split(df: pd.DataFrame, size: int, shuffle: bool = False, seed: int = 42) -> list[pd.DataFrame]:
61
+ """Cut a table into batches of ``size`` rows; 0 keeps it whole."""
62
+ if shuffle:
63
+ df = df.sample(frac=1, random_state=seed).reset_index(drop=True)
64
+ size = size if size > 0 else len(df)
65
+ return [df.iloc[i:i + size] for i in range(0, len(df), size)]
66
+
67
+
68
+ def _existing(ws_root: Path) -> list[Path]:
69
+ return sorted((ws_root / "batches").glob(f"{BATCH_PREFIX}*.csv"))
70
+
71
+
72
+ def _write_batches(root: Path, parts: list[pd.DataFrame]) -> list[str]:
73
+ taken = [int(m.group(1)) for p in _existing(root)
74
+ if (m := re.fullmatch(rf"{BATCH_PREFIX}(\d+)", p.stem))]
75
+ number = max(taken, default=0)
76
+ names = []
77
+ for part in parts:
78
+ number += 1
79
+ name = f"{BATCH_PREFIX}{number:03d}"
80
+ part.to_csv(root / "batches" / f"{name}.csv", index=False)
81
+ names.append(name)
82
+ return names
83
+
84
+
85
+ def assign(ws: WorkspaceConfig, batches: list[str], people: list[str], per_batch: int = 1) -> None:
86
+ """Deal batches out in turn, each to ``per_batch`` different people."""
87
+ if not people:
88
+ return
89
+ data = accounts.load_assignments(ws)
90
+ per_batch = min(max(per_batch, 1), len(people))
91
+ for i, batch in enumerate(batches):
92
+ for k in range(per_batch):
93
+ person = people[(i * per_batch + k) % len(people)]
94
+ queue = data.setdefault(person, {"batches": []})["batches"]
95
+ if batch not in queue:
96
+ queue.append(batch)
97
+ accounts.save_assignments(ws, data)
98
+
99
+
100
+ def _new_id(name: str) -> str:
101
+ base = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") or "project"
102
+ taken = {p.name for p in projects_root().glob("*")}
103
+ for manifest in registry.manifest_paths():
104
+ taken.add(registry._peek_id(manifest))
105
+ found, n = base, 1
106
+ while found in taken:
107
+ n += 1
108
+ found = f"{base}_{n}"
109
+ return found
110
+
111
+
112
+ def create_project(name: str, task: str, instructions: str, table: pd.DataFrame | None,
113
+ display: list[str], owner: str, batch_size: int = 0, shuffle: bool = False,
114
+ labels: list[str] | None = None, scale: dict | None = None,
115
+ people: list[str] | None = None, per_batch: int = 1) -> WorkspaceConfig:
116
+ name = name.strip()
117
+ if not name:
118
+ raise ValueError("Give the project a name.")
119
+ if task not in TASKS:
120
+ raise ValueError(f"Unknown task '{task}'.")
121
+ if task == "choice" and len(labels or []) < 2:
122
+ raise ValueError("Add at least two labels.")
123
+ if task == "rating" and not (scale and scale["min"] < scale["max"] and scale["step"] > 0):
124
+ raise ValueError("The slider needs a minimum below its maximum and a step above 0.")
125
+ if table is not None and not display:
126
+ raise ValueError("Choose at least one column to show.")
127
+
128
+ ws_id = _new_id(name)
129
+ project = {"id": ws_id, "name": name, "task": task, "default": True,
130
+ "batch_prefix": BATCH_PREFIX, "display": list(display or []),
131
+ "instructions": instructions.strip()}
132
+ if task == "choice":
133
+ project["labels"] = list(labels)
134
+ if task == "rating":
135
+ project["scale"] = scale
136
+ manifest = {"workspace": {"id": ws_id, "name": name, "owner": owner,
137
+ "batches_dir": "batches", "progress_dir": "progress"},
138
+ "projects": [project]}
139
+ root = projects_root() / ws_id
140
+ (root / "batches").mkdir(parents=True)
141
+ try:
142
+ (root / MANIFEST_NAME).write_text(yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True),
143
+ encoding="utf-8")
144
+ batches = [] if table is None else _write_batches(root, split(table, batch_size, shuffle))
145
+ registry.add(root)
146
+ except (ConfigError, OSError, ValueError):
147
+ shutil.rmtree(root, ignore_errors=True)
148
+ raise
149
+ ws = load_workspace(root)
150
+ assign(ws, batches, people or [], per_batch)
151
+ return ws
152
+
153
+
154
+ def _update_project(ws: WorkspaceConfig, **fields) -> None:
155
+ raw = yaml.safe_load(ws.manifest.read_text(encoding="utf-8"))
156
+ raw["projects"][0].update(fields)
157
+ ws.manifest.write_text(yaml.safe_dump(raw, sort_keys=False, allow_unicode=True), encoding="utf-8")
158
+
159
+
160
+ def add_data(ws: WorkspaceConfig, table: pd.DataFrame, id_column: str | None, batch_size: int = 0,
161
+ shuffle: bool = False, people: list[str] | None = None, per_batch: int = 1,
162
+ display: list[str] | None = None) -> list[str]:
163
+ """Add rows to a project made here, as new batches; existing batches are untouched.
164
+
165
+ ``display`` picks the columns to show when the project has none chosen yet.
166
+ """
167
+ seen: list[str] = []
168
+ for path in _existing(ws.root):
169
+ seen += pd.read_csv(path, dtype=str, keep_default_na=False)["id"].tolist()
170
+ new = with_ids(table, id_column, start=len(seen) + 1)
171
+ clash = sorted(set(new["id"]) & set(seen))
172
+ if clash:
173
+ raise ValueError(f"These ids are already in the project: {clash[:5]}")
174
+ shown = [f.column for f in ws.projects[0].display]
175
+ missing = [c for c in shown if c not in new.columns]
176
+ if missing:
177
+ raise ValueError(f"The CSV is missing the column(s) annotators see: {missing}")
178
+ if display and not shown:
179
+ _update_project(ws, display=list(display))
180
+ batches = _write_batches(ws.root, split(new, batch_size, shuffle))
181
+ assign(ws, batches, people or [], per_batch)
182
+ return batches
183
+
184
+
185
+ def add_labels(ws: WorkspaceConfig, new: list[str]) -> None:
186
+ """Add label buttons to a project made here. Labels are never removed: saved answers use them."""
187
+ if ws.projects[0].task != "choice":
188
+ raise ValueError("Only label-button projects have labels.")
189
+ current = yaml.safe_load(ws.manifest.read_text(encoding="utf-8"))["projects"][0]["labels"]
190
+ _update_project(ws, labels=list(dict.fromkeys(current + new)))