runboard-ml 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. runboard/__init__.py +2 -0
  2. runboard/__main__.py +3 -0
  3. runboard/access.py +219 -0
  4. runboard/backends/__init__.py +12 -0
  5. runboard/backends/base.py +69 -0
  6. runboard/backends/kaggle.py +261 -0
  7. runboard/backends/local.py +114 -0
  8. runboard/backends/remote.py +306 -0
  9. runboard/cli.py +202 -0
  10. runboard/code.py +150 -0
  11. runboard/demo/evaluate.py +57 -0
  12. runboard/demo/plan.yaml +26 -0
  13. runboard/demo/runboard.yaml +46 -0
  14. runboard/demo/runs.yaml +13 -0
  15. runboard/demo/train.py +86 -0
  16. runboard/errors.py +167 -0
  17. runboard/export.py +325 -0
  18. runboard/home.py +153 -0
  19. runboard/hypotheses.py +373 -0
  20. runboard/localrun.py +36 -0
  21. runboard/planfile.py +195 -0
  22. runboard/prereg.py +211 -0
  23. runboard/proc.py +69 -0
  24. runboard/progress.py +157 -0
  25. runboard/project.py +707 -0
  26. runboard/qr.py +154 -0
  27. runboard/rb.py +190 -0
  28. runboard/research.py +226 -0
  29. runboard/runsfile.py +185 -0
  30. runboard/runtime.py +516 -0
  31. runboard/server.py +1020 -0
  32. runboard/static/icon.svg +1 -0
  33. runboard/static/index.html +443 -0
  34. runboard/static/js/01-i18n.js +160 -0
  35. runboard/static/js/02-core.js +202 -0
  36. runboard/static/js/03-charts.js +377 -0
  37. runboard/static/js/04-pages.js +318 -0
  38. runboard/static/js/05-plan.js +296 -0
  39. runboard/static/js/05-templates.js +53 -0
  40. runboard/static/js/06-code.js +98 -0
  41. runboard/static/js/06-project.js +100 -0
  42. runboard/static/js/07-system.js +106 -0
  43. runboard/static/js/08-wizard.js +275 -0
  44. runboard/static/js/09-hypotheses.js +72 -0
  45. runboard/static/js/10-main.js +84 -0
  46. runboard/static/mini.html +148 -0
  47. runboard/static/pair.html +56 -0
  48. runboard/templates/check.py +147 -0
  49. runboard/templates/convert.py +113 -0
  50. runboard/templates/evaluate.py +162 -0
  51. runboard/templates/runboard_train.py +136 -0
  52. runboard/templates/speed.py +66 -0
  53. runboard/templates/split.py +99 -0
  54. runboard/templates/subsets.py +80 -0
  55. runboard/templating.py +260 -0
  56. runboard/watch.py +101 -0
  57. runboard/wizard.py +281 -0
  58. runboard_ml-0.2.0.dist-info/METADATA +187 -0
  59. runboard_ml-0.2.0.dist-info/RECORD +63 -0
  60. runboard_ml-0.2.0.dist-info/WHEEL +5 -0
  61. runboard_ml-0.2.0.dist-info/entry_points.txt +2 -0
  62. runboard_ml-0.2.0.dist-info/licenses/LICENSE +21 -0
  63. runboard_ml-0.2.0.dist-info/top_level.txt +1 -0
runboard/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """runboard: one self-hosted dashboard for ML training runs across projects and compute backends."""
2
+ __version__ = "0.2.0"
runboard/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
runboard/access.py ADDED
@@ -0,0 +1,219 @@
1
+ """Who may use the dashboard.
2
+
3
+ - This computer (a loopback connection) needs nothing: no password, no login screen.
4
+ - Other devices (a tablet on the same Wi-Fi) only when network access is switched on, and only after pairing once:
5
+ this computer shows a QR code / 6-digit code, the device gets a long random key in a cookie, and only a hash of that
6
+ key is stored (~/.runboard/devices.json). Paired devices can be removed at any time.
7
+ - Every request is also checked against DNS rebinding (Host header) and cross-site requests (Origin), so a web page
8
+ open in a browser cannot drive the dashboard.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import copy
13
+ import hashlib
14
+ import ipaddress
15
+ import secrets
16
+ import socket
17
+ import threading
18
+ import time
19
+ from http.cookies import SimpleCookie
20
+
21
+ from . import home
22
+
23
+ COOKIE = "rb_device"
24
+ PAIR_SECONDS = 120
25
+ PAIR_TRIES = 5
26
+ _lock = threading.Lock()
27
+ _pairing: dict | None = None
28
+
29
+
30
+ # ---------------------------------------------------------------- devices
31
+
32
+ def _devices_file():
33
+ return home.HOME / "devices.json"
34
+
35
+
36
+ _devices_cache: tuple = (None, [])
37
+
38
+
39
+ def devices() -> list[dict]:
40
+ """Paired devices; every request from a tablet asks, so the file is read again only when it changed."""
41
+ global _devices_cache
42
+ f = _devices_file()
43
+ try:
44
+ st = f.stat()
45
+ sig = (str(f), st.st_mtime_ns, st.st_size)
46
+ except FileNotFoundError:
47
+ return []
48
+ if _devices_cache[0] != sig:
49
+ _devices_cache = (sig, home.read_json(f, []))
50
+ return copy.deepcopy(_devices_cache[1])
51
+
52
+
53
+ def _hash(key: str) -> str:
54
+ return hashlib.sha256(key.encode()).hexdigest()
55
+
56
+
57
+ def add_device(name: str) -> str:
58
+ """Creates a device and returns its cookie value (the key itself is never stored)."""
59
+ did, key = secrets.token_hex(4), secrets.token_urlsafe(32)
60
+ with _lock:
61
+ ds = devices()
62
+ ds.append({"id": did, "name": name[:60] or "device", "hash": _hash(key),
63
+ "created": time.strftime("%Y-%m-%d %H:%M"), "last_seen": None})
64
+ home.write_json(_devices_file(), ds)
65
+ return f"{did}.{key}"
66
+
67
+
68
+ def remove_device(did: str) -> bool:
69
+ with _lock:
70
+ ds = devices()
71
+ keep = [d for d in ds if d["id"] != did]
72
+ home.write_json(_devices_file(), keep)
73
+ return len(keep) != len(ds)
74
+
75
+
76
+ def device_for(cookie_value: str | None) -> dict | None:
77
+ if not cookie_value or "." not in cookie_value:
78
+ return None
79
+ did, key = cookie_value.split(".", 1)
80
+ h = _hash(key)
81
+ for d in devices():
82
+ if d["id"] == did and secrets.compare_digest(d["hash"], h):
83
+ if not d.get("last_seen") or d["last_seen"][:16] != time.strftime("%Y-%m-%d %H:%M"):
84
+ with _lock:
85
+ ds = devices()
86
+ for x in ds:
87
+ if x["id"] == did:
88
+ x["last_seen"] = time.strftime("%Y-%m-%d %H:%M")
89
+ home.write_json(_devices_file(), ds)
90
+ return d
91
+ return None
92
+
93
+
94
+ def device_name(user_agent: str) -> str:
95
+ ua = user_agent or ""
96
+ for key, name in (("iPad", "iPad"), ("iPhone", "iPhone"), ("Android", "Android"), ("Macintosh", "Mac"),
97
+ ("Windows", "Windows"), ("Linux", "Linux")):
98
+ if key in ua:
99
+ return name + (" tablet" if key == "Android" and "Mobile" not in ua else "")
100
+ return "device"
101
+
102
+
103
+ # ---------------------------------------------------------------- pairing
104
+
105
+ def start_pairing() -> dict:
106
+ """A new one-time pairing: a token for the QR link and a 6-digit code to type by hand. The previous one ends."""
107
+ global _pairing
108
+ with _lock:
109
+ _pairing = {"token": secrets.token_urlsafe(16), "code": f"{secrets.randbelow(10**6):06d}",
110
+ "expires": time.time() + PAIR_SECONDS, "tries": 0}
111
+ return dict(_pairing)
112
+
113
+
114
+ def redeem(token: str | None = None, code: str | None = None) -> bool:
115
+ """True once for the current pairing's token or code; wrong codes count, and after PAIR_TRIES it is void."""
116
+ global _pairing
117
+ with _lock:
118
+ p = _pairing
119
+ if not p or time.time() > p["expires"]:
120
+ _pairing = None
121
+ return False
122
+ ok = (token is not None and secrets.compare_digest(token, p["token"])) or \
123
+ (code is not None and secrets.compare_digest(str(code).strip(), p["code"]))
124
+ if ok:
125
+ _pairing = None
126
+ return True
127
+ p["tries"] += 1
128
+ if p["tries"] >= PAIR_TRIES:
129
+ _pairing = None
130
+ return False
131
+
132
+
133
+ def pairing_state() -> dict | None:
134
+ p = _pairing
135
+ return {"seconds": max(0, round(p["expires"] - time.time()))} if p and time.time() < p["expires"] else None
136
+
137
+
138
+ # ---------------------------------------------------------------- request checks
139
+
140
+ def _local_addresses() -> set[str]:
141
+ out = {"127.0.0.1", "::1"}
142
+ try:
143
+ out.update(socket.gethostbyname_ex(socket.gethostname())[2])
144
+ except OSError:
145
+ pass
146
+ return out
147
+
148
+
149
+ LOCAL_ADDRESSES = _local_addresses()
150
+
151
+
152
+ def is_local_client(host: str | None) -> bool:
153
+ if not host:
154
+ return False
155
+ host = host.removeprefix("::ffff:")
156
+ return host in LOCAL_ADDRESSES or host.startswith("127.")
157
+
158
+
159
+ def host_allowed(host_header: str, extra: list[str]) -> bool:
160
+ """DNS rebinding guard: a request must name this machine by IP, as localhost, by its own name or by a name listed
161
+ in config.yaml (allowed_hosts, e.g. a Tailscale name). A web page's own domain is never accepted."""
162
+ host = host_header.strip().lower()
163
+ if host.startswith("["):
164
+ host = host[1:host.find("]")]
165
+ elif host.count(":") == 1:
166
+ host = host.split(":")[0]
167
+ try:
168
+ ipaddress.ip_address(host)
169
+ return True
170
+ except ValueError:
171
+ pass
172
+ names = {"localhost", socket.gethostname().lower()} | {h.lower() for h in extra}
173
+ return host in names or host.endswith(".localhost")
174
+
175
+
176
+ def cross_site(method: str, headers: dict[str, str]) -> bool:
177
+ """CSRF guard for anything that changes state: another site's page may not send it."""
178
+ if method in ("GET", "HEAD", "OPTIONS"):
179
+ return False
180
+ if headers.get("sec-fetch-site", "same-origin") not in ("same-origin", "none"):
181
+ return True
182
+ origin = headers.get("origin")
183
+ if origin and origin != "null":
184
+ return origin.split("://", 1)[-1].lower() != headers.get("host", "").lower()
185
+ return origin == "null"
186
+
187
+
188
+ def cookie_value(headers: dict[str, str]) -> str | None:
189
+ c = SimpleCookie()
190
+ try:
191
+ c.load(headers.get("cookie", ""))
192
+ except Exception:
193
+ return None
194
+ return c[COOKIE].value if COOKIE in c else None
195
+
196
+
197
+ def set_cookie_header(value: str) -> str:
198
+ return f"{COOKIE}={value}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Strict"
199
+
200
+
201
+ CSP = ("default-src 'self'; script-src 'self'{scripts}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; "
202
+ "media-src 'self' blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
203
+ SECURITY_HEADERS = [
204
+ (b"x-frame-options", b"DENY"),
205
+ (b"x-content-type-options", b"nosniff"),
206
+ (b"referrer-policy", b"no-referrer"),
207
+ (b"content-security-policy", CSP.format(scripts="").encode()),
208
+ ]
209
+
210
+
211
+ def page_csp(html: str) -> str:
212
+ """The policy for one page: only the page's own scripts run, named by their SHA-256. A script that got into the page
213
+ any other way (an injected tag or an onclick attribute) is refused by the browser."""
214
+ import base64
215
+ import re
216
+ hashes = [base64.b64encode(hashlib.sha256(m.encode("utf-8")).digest()).decode()
217
+ for m in re.findall(r"<script>(.*?)</script>", html, re.S)]
218
+ return CSP.format(scripts="".join(f" 'sha256-{h}'" for h in hashes))
219
+
@@ -0,0 +1,12 @@
1
+ """Compute backends. Each one launches a run, reports its status, streams its log and brings its outputs home.
2
+
3
+ Adding a backend: subclass Backend in a new module and register it in BACKENDS.
4
+ """
5
+ from .base import ACTIVE, DONE, Backend
6
+ from .kaggle import KaggleBackend
7
+ from .local import LocalBackend
8
+ from .remote import SlurmBackend, SSHBackend
9
+
10
+ BACKENDS: dict[str, Backend] = {b.name: b for b in (KaggleBackend(), LocalBackend(), SSHBackend(), SlurmBackend())}
11
+
12
+ __all__ = ["ACTIVE", "DONE", "Backend", "BACKENDS"]
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable
4
+
5
+ # Normalised run states shared by every backend.
6
+ ACTIVE = {"QUEUED", "STARTING", "RUNNING", "CANCELLING"}
7
+ DONE = {"COMPLETE", "ERROR", "CANCELLED"}
8
+
9
+
10
+ class Backend:
11
+ name = "base"
12
+ label = "Base"
13
+ can_cancel = False
14
+ has_data = False # can upload training data packages
15
+ has_quota = False
16
+
17
+ def slot(self, project) -> str | None:
18
+ """What a run occupies while active: one run per slot (None: no limit, the backend queues its own)."""
19
+ return self.name
20
+
21
+ def available(self) -> tuple[bool, str | None]:
22
+ """(usable, reason if not)."""
23
+ return True, None
24
+
25
+ def info(self) -> dict:
26
+ """Account / machine facts shown on the dashboard (quota, GPU …)."""
27
+ return {}
28
+
29
+ def submit(self, project, run_id: str, info: dict) -> dict:
30
+ """Launches the run; returns fields stored in the run's state (handle, url …)."""
31
+ raise NotImplementedError
32
+
33
+ def status(self, project, run_id: str, st: dict) -> tuple[str, str | None]:
34
+ """(normalised status, failure message)."""
35
+ raise NotImplementedError
36
+
37
+ def stream(self, project, run_id: str, st: dict, sink: list[str], alive: Callable[[], bool]) -> None:
38
+ """Appends live log lines to sink until the run ends (blocking)."""
39
+ raise NotImplementedError
40
+
41
+ def final_log(self, project, run_id: str, st: dict) -> list[str]:
42
+ return []
43
+
44
+ def fetch(self, project, run_id: str, st: dict) -> None:
45
+ """Brings outputs into the project's output directory (no-op when they are already local)."""
46
+
47
+ def cancel(self, project, run_id: str, st: dict) -> None:
48
+ raise NotImplementedError("This backend cannot cancel runs")
49
+
50
+ def resume_info(self, project, run_id: str, st: dict, info: dict) -> dict:
51
+ """What a resumed attempt needs ({resume} for the command template), or an error why it cannot resume."""
52
+ from ..errors import Invalid
53
+ cfg = (project.m.get("backends") or {}).get(self.name) or {}
54
+ if not cfg.get("resume"):
55
+ raise Invalid("no_resume", backend=self.label)
56
+ if st.get("status") not in ("ERROR", "CANCELLED") or st.get("backend") != self.name:
57
+ raise Invalid("cannot_resume", run=run_id)
58
+ return self._resume_info(project, run_id, st, info)
59
+
60
+ def _resume_info(self, project, run_id: str, st: dict, info: dict) -> dict:
61
+ raise NotImplementedError
62
+
63
+ def may_still_run(self, project, run_id: str, st: dict) -> bool:
64
+ """True when a run already marked as finished could in fact still be going (it is then checked again)."""
65
+ return False
66
+
67
+ def delete(self, project, run_id: str, st: dict) -> None:
68
+ """Removes the remote copy of a finished run (optional)."""
69
+ raise NotImplementedError
@@ -0,0 +1,261 @@
1
+ """Kaggle Notebooks: each run becomes a private single-file script kernel; data packages become private datasets.
2
+
3
+ The API token stays on this machine (~/.kaggle/access_token or KAGGLE_API_TOKEN) and is never sent to the browser.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import base64
8
+ import json
9
+ import os
10
+ import re
11
+ import shutil
12
+ import zipfile
13
+ from datetime import timezone
14
+ from pathlib import Path
15
+ from typing import Callable
16
+
17
+ from ..errors import Invalid
18
+ from ..home import write_json
19
+ from ..progress import fix_text, log_lines
20
+ from .base import Backend
21
+
22
+ STATUS = {"QUEUED": "QUEUED", "RUNNING": "RUNNING", "NEW_SCRIPT": "STARTING", "COMPLETE": "COMPLETE", "ERROR": "ERROR",
23
+ "CANCEL_REQUESTED": "CANCELLING", "CANCEL_ACKNOWLEDGED": "CANCELLED"}
24
+
25
+
26
+ def api():
27
+ import contextlib
28
+ import io
29
+
30
+ from kaggle.api.kaggle_api_extended import KaggleApi
31
+
32
+ a = KaggleApi()
33
+ # without a token, newer clients print a sign-in guide and call exit(1) instead of raising
34
+ with contextlib.redirect_stdout(io.StringIO()):
35
+ try:
36
+ a.authenticate()
37
+ except SystemExit:
38
+ raise RuntimeError("no Kaggle token (~/.kaggle/access_token)") from None
39
+ return a
40
+
41
+
42
+ def user(a) -> str:
43
+ return a.get_config_value(a.CONFIG_NAME_USER)
44
+
45
+
46
+ class KaggleBackend(Backend):
47
+ name = "kaggle"
48
+ label = "Kaggle"
49
+ has_data = True
50
+ has_quota = True
51
+
52
+ def available(self):
53
+ try:
54
+ import kaggle # noqa: F401
55
+ except ImportError:
56
+ return False, "pip install kaggle"
57
+ try:
58
+ api()
59
+ return True, None
60
+ except Exception as e: # missing or invalid token
61
+ return False, (str(e).strip().splitlines() or [type(e).__name__])[-1]
62
+
63
+ def info(self) -> dict:
64
+ a = api()
65
+ out = {"user": user(a)}
66
+ try:
67
+ q = a.quota_view()
68
+ g = q.gpu_quota
69
+ out["quota"] = {"used_h": round(g.time_used.total_seconds() / 3600, 1),
70
+ "total_h": round(g.total_time_allowed.total_seconds() / 3600, 1),
71
+ "refresh": q.quota_refresh_time.replace(tzinfo=timezone.utc).isoformat() if q.quota_refresh_time else None}
72
+ except Exception:
73
+ pass
74
+ return out
75
+
76
+ # ------------------------------------------------------------ runs
77
+
78
+ def _cfg(self, project) -> dict:
79
+ return (project.m.get("backends") or {}).get("kaggle") or {}
80
+
81
+ def ref(self, project, run_id: str, st: dict, a=None) -> str:
82
+ r = f"{user(a or api())}/{st.get('kernel') or self.slug(project, run_id)}"
83
+ return f"{r}/{st['version']}" if st.get("version") else r
84
+
85
+ def slug(self, project, run_id: str) -> str:
86
+ return re.sub(r"[^a-z0-9-]+", "-", project.render(self._cfg(project).get("kernel", project.key + "-{id|lower}"),
87
+ {"id": run_id}).lower())
88
+
89
+ def dataset_for(self, project, info: dict) -> str | None:
90
+ data = project.m.get("data") or {}
91
+ field = self._cfg(project).get("data_field") or data.get("field")
92
+ if not field:
93
+ return None
94
+ value = info.get(field, project.spec(info["id"]).get(field)) # the field need not be a displayed one
95
+ return project.state.datasets(data.get("datasets") or {}).get(value)
96
+
97
+ def build(self, project, run_id: str, info: dict, folder: Path, owner: str) -> None:
98
+ cfg = self._cfg(project)
99
+ files = {dst: base64.b64encode((project.root / src).read_bytes()).decode()
100
+ for dst, src in (cfg.get("files") or {}).items()}
101
+ setup = [project.render(s, info) for s in cfg.get("setup", [])]
102
+ command = project.render(cfg.get("resume") if info.get("resume") else cfg.get("command", ""), info)
103
+ code = (f"# runboard · {project.m.get('name', project.key)} · {run_id} — generated, do not edit.\n"
104
+ "import base64, json, os, pathlib, subprocess, sys\n"
105
+ f"FILES = json.loads({json.dumps(json.dumps(files))})\n"
106
+ "d = pathlib.Path('/tmp/runboard-code'); d.mkdir(parents=True, exist_ok=True)\n"
107
+ "for name, b in FILES.items():\n"
108
+ " (d / name).parent.mkdir(parents=True, exist_ok=True); (d / name).write_bytes(base64.b64decode(b))\n"
109
+ f"for cmd in {json.dumps(setup)}:\n"
110
+ " subprocess.check_call(cmd, shell=True)\n"
111
+ "os.environ['PYTHONUNBUFFERED'] = '1'\n"
112
+ f"sys.exit(subprocess.call({json.dumps(command)}, shell=True, cwd=d))\n")
113
+ shutil.rmtree(folder, ignore_errors=True)
114
+ folder.mkdir(parents=True)
115
+ (folder / "run.py").write_text(code, encoding="utf-8")
116
+ ds = self.dataset_for(project, info)
117
+ slug = info.get("_kernel") or self.slug(project, run_id)
118
+ write_json(folder / "kernel-metadata.json", {
119
+ "id": f"{owner}/{slug}", "title": slug, "code_file": "run.py", "language": "python", "kernel_type": "script",
120
+ "is_private": True, "enable_gpu": bool(cfg.get("accelerator", "NvidiaTeslaT4")), "enable_tpu": False,
121
+ "enable_internet": cfg.get("internet", True), "machine_shape": cfg.get("accelerator", "NvidiaTeslaT4"),
122
+ "dataset_sources": [f"{owner}/{ds}"] if ds else [], "competition_sources": [],
123
+ "kernel_sources": [f"{owner}/{info['_from_kernel']}"] if info.get("_from_kernel") else [],
124
+ "model_sources": []})
125
+
126
+ def submit(self, project, run_id, info):
127
+ data = project.m.get("data") or {}
128
+ field = self._cfg(project).get("data_field") or data.get("field")
129
+ if field and not self.dataset_for(project, info):
130
+ raise Invalid("not_on_kaggle", data=project.spec(run_id).get(field))
131
+ slug = info.get("_kernel") or self.slug(project, run_id)
132
+ folder = project.state.dir / "push" / slug
133
+ try: # Kaggle's own wording is kept, inside a message the page can put in its language
134
+ a = api()
135
+ self.build(project, run_id, info, folder, user(a))
136
+ res = a.kernels_push(str(folder))
137
+ except Exception as e: # noqa: BLE001
138
+ raise Invalid("kaggle_error", problem=(str(e).strip().splitlines() or [type(e).__name__])[-1][:300])
139
+ if res.error:
140
+ raise Invalid("kaggle_error", problem=str(res.error)[:300])
141
+ return {"kernel": slug, "version": res.version_number, "url": res.url}
142
+
143
+ def _resume_info(self, project, run_id, st, info):
144
+ """A new kernel (<slug>-r<attempt>) with the stopped kernel's output attached; Kaggle mounts it under
145
+ /kaggle/input/<stopped kernel>/. This works when Kaggle kept the outputs of the stopped version."""
146
+ prev = st.get("kernel") or self.slug(project, run_id)
147
+ new = f"{self.slug(project, run_id)}-r{st.get('attempt', 1) + 1}"
148
+ return {"resume": f"/kaggle/input/{prev}/{info['name']}", "_kernel": new, "_from_kernel": prev}
149
+
150
+ def status(self, project, run_id, st):
151
+ res = api().kernels_status(self.ref(project, run_id, st))
152
+ s = res.status.name if hasattr(res.status, "name") else str(res.status)
153
+ return STATUS.get(s, s), res.failure_message or None
154
+
155
+ def stream(self, project, run_id, st, sink, alive: Callable[[], bool]):
156
+ for ev in api().kernels_logs_stream(self.ref(project, run_id, st)):
157
+ if not alive(): # the run stopped or the dashboard is closing: let the thread end
158
+ return
159
+ for line in re.split(r"[\r\n]+", fix_text(ev.get("data", ""))):
160
+ if line.strip() and not re.search(r"Debugger warning|frozen modules|PYDEVD|debugger miss", line):
161
+ sink.append(line.rstrip())
162
+
163
+ def final_log(self, project, run_id, st):
164
+ return log_lines(api().kernels_logs(self.ref(project, run_id, st)))
165
+
166
+ def fetch(self, project, run_id, st):
167
+ a = api()
168
+ target = project.root / self._cfg(project).get("output_dir", "runs/kaggle")
169
+ token = None
170
+ while True:
171
+ _, token = a.kernels_output(self.ref(project, run_id, st, a), str(target), page_token=token, force=True)
172
+ if not token:
173
+ break
174
+
175
+ def delete(self, project, run_id, st):
176
+ a = api()
177
+ a.kernels_delete(f"{user(a)}/{st.get('kernel') or self.slug(project, run_id)}", no_confirm=True)
178
+
179
+ # ------------------------------------------------------------ data
180
+
181
+ def datasets(self, project) -> list[dict]:
182
+ a = api()
183
+ owner = user(a)
184
+ mine = {d.ref.split("/")[1]: d for d in (a.dataset_list(mine=True) or []) if d}
185
+ data = project.m.get("data") or {}
186
+ out = []
187
+ for pkg, name in project.state.datasets(data.get("datasets") or {}).items():
188
+ d = mine.get(name)
189
+ try:
190
+ st = a.dataset_status(f"{owner}/{name}") if d else None
191
+ except Exception:
192
+ st = None
193
+ out.append({"key": pkg, "dataset": name, "exists": bool(d), "status": st,
194
+ "version": getattr(d, "current_version_number", None),
195
+ "gb": round((getattr(d, "total_bytes", 0) or 0) / 2**30, 2),
196
+ "private": getattr(d, "is_private", None)})
197
+ return out
198
+
199
+ @staticmethod
200
+ def zip_folder(src: Path, dst: Path, forbid: list, say=print) -> Path:
201
+ files = [f for f in src.rglob("*") if f.is_file()]
202
+ keep = [f for f in files if not any(rx.search(f.relative_to(src).as_posix()) for rx in forbid)]
203
+ say(f"▶ zipping {src} ({len(keep)} files" + (f", {len(files) - len(keep)} left out by the forbidden-path rules" if len(keep) < len(files) else "") + ")…")
204
+ dst.parent.mkdir(parents=True, exist_ok=True)
205
+ tmp = dst.with_suffix(".tmp")
206
+ with zipfile.ZipFile(tmp, "w", zipfile.ZIP_STORED) as z: # images are already compressed
207
+ for i, f in enumerate(keep, 1):
208
+ z.write(f, f.relative_to(src).as_posix())
209
+ if i % 2000 == 0:
210
+ say(f" {i}/{len(keep)}")
211
+ tmp.replace(dst)
212
+ return dst
213
+
214
+ def upload(self, project, keys: list[str], say=print) -> int:
215
+ """Uploads each package to its own private dataset (new version if it exists). Runs as a background job."""
216
+ data = project.m.get("data") or {}
217
+ forbid = [re.compile(p) for p in data.get("forbid", [])]
218
+ catalog = {r["_key"]: r for r in project.catalog()}
219
+ a = api()
220
+ owner = user(a)
221
+ mine = {d.ref.split("/")[1] for d in (a.dataset_list(mine=True) or []) if d}
222
+ dmap = project.state.datasets(data.get("datasets") or {})
223
+ prefix = data.get("dataset_prefix", project.key + "-")
224
+ for key in keys:
225
+ row = catalog.get(key, {})
226
+ z = Path(row["_zip"]) if row else project.root / data.get("dir", ".") / f"{key}.zip"
227
+ if row.get("_dir"): # a folder package: zip it now, leaving out forbidden paths (e.g. the test split)
228
+ z = self.zip_folder(Path(row["_dir"]), z, forbid, say)
229
+ if not z.exists():
230
+ say(f"ERROR: {z} not found")
231
+ return 1
232
+ if forbid:
233
+ say(f"▶ {key}: checking the archive against forbidden paths…")
234
+ with zipfile.ZipFile(z) as f:
235
+ bad = [n for n in f.namelist() if any(rx.search(n) for rx in forbid)]
236
+ if bad:
237
+ say(f"ERROR: {key} contains forbidden files (e.g. {bad[0]}); not uploaded")
238
+ return 1
239
+ name = dmap.get(key) or re.sub(r"[^a-z0-9-]+", "-", f"{prefix}{key}".lower())
240
+ folder = project.state.dir / "upload" / name
241
+ shutil.rmtree(folder, ignore_errors=True)
242
+ folder.mkdir(parents=True)
243
+ try:
244
+ os.link(z, folder / z.name)
245
+ except OSError:
246
+ shutil.copy2(z, folder / z.name)
247
+ write_json(folder / "dataset-metadata.json", {"title": name, "id": f"{owner}/{name}", "licenses": [{"name": "other"}]})
248
+ gb = z.stat().st_size / 2**30
249
+ if name in mine:
250
+ say(f"▶ {key}: new version of {owner}/{name} ({gb:.2f} GB, private)…")
251
+ res = a.dataset_create_version(str(folder), f"{key} · runboard", quiet=False)
252
+ else:
253
+ say(f"▶ {key}: creating private dataset {owner}/{name} ({gb:.2f} GB)…")
254
+ res = a.dataset_create_new(str(folder), public=False, quiet=False)
255
+ shutil.rmtree(folder, ignore_errors=True)
256
+ if res.error:
257
+ say(f"ERROR: {key}: {res.error}")
258
+ return 1
259
+ project.state.set_dataset(key, name)
260
+ say(f"✔ {key} uploaded: {res.url} (Kaggle processes it for a few minutes)")
261
+ return 0