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.
- runboard/__init__.py +2 -0
- runboard/__main__.py +3 -0
- runboard/access.py +219 -0
- runboard/backends/__init__.py +12 -0
- runboard/backends/base.py +69 -0
- runboard/backends/kaggle.py +261 -0
- runboard/backends/local.py +114 -0
- runboard/backends/remote.py +306 -0
- runboard/cli.py +202 -0
- runboard/code.py +150 -0
- runboard/demo/evaluate.py +57 -0
- runboard/demo/plan.yaml +26 -0
- runboard/demo/runboard.yaml +46 -0
- runboard/demo/runs.yaml +13 -0
- runboard/demo/train.py +86 -0
- runboard/errors.py +167 -0
- runboard/export.py +325 -0
- runboard/home.py +153 -0
- runboard/hypotheses.py +373 -0
- runboard/localrun.py +36 -0
- runboard/planfile.py +195 -0
- runboard/prereg.py +211 -0
- runboard/proc.py +69 -0
- runboard/progress.py +157 -0
- runboard/project.py +707 -0
- runboard/qr.py +154 -0
- runboard/rb.py +190 -0
- runboard/research.py +226 -0
- runboard/runsfile.py +185 -0
- runboard/runtime.py +516 -0
- runboard/server.py +1020 -0
- runboard/static/icon.svg +1 -0
- runboard/static/index.html +443 -0
- runboard/static/js/01-i18n.js +160 -0
- runboard/static/js/02-core.js +202 -0
- runboard/static/js/03-charts.js +377 -0
- runboard/static/js/04-pages.js +318 -0
- runboard/static/js/05-plan.js +296 -0
- runboard/static/js/05-templates.js +53 -0
- runboard/static/js/06-code.js +98 -0
- runboard/static/js/06-project.js +100 -0
- runboard/static/js/07-system.js +106 -0
- runboard/static/js/08-wizard.js +275 -0
- runboard/static/js/09-hypotheses.js +72 -0
- runboard/static/js/10-main.js +84 -0
- runboard/static/mini.html +148 -0
- runboard/static/pair.html +56 -0
- runboard/templates/check.py +147 -0
- runboard/templates/convert.py +113 -0
- runboard/templates/evaluate.py +162 -0
- runboard/templates/runboard_train.py +136 -0
- runboard/templates/speed.py +66 -0
- runboard/templates/split.py +99 -0
- runboard/templates/subsets.py +80 -0
- runboard/templating.py +260 -0
- runboard/watch.py +101 -0
- runboard/wizard.py +281 -0
- runboard_ml-0.2.0.dist-info/METADATA +187 -0
- runboard_ml-0.2.0.dist-info/RECORD +63 -0
- runboard_ml-0.2.0.dist-info/WHEEL +5 -0
- runboard_ml-0.2.0.dist-info/entry_points.txt +2 -0
- runboard_ml-0.2.0.dist-info/licenses/LICENSE +21 -0
- runboard_ml-0.2.0.dist-info/top_level.txt +1 -0
runboard/__init__.py
ADDED
runboard/__main__.py
ADDED
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
|