bindsight 0.3.5__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.
- bindsight/README.md +44 -0
- bindsight/__init__.py +15 -0
- bindsight/benchmark/__init__.py +71 -0
- bindsight/benchmark/core.py +497 -0
- bindsight/benchmark/defaults.py +28 -0
- bindsight/benchmark/designer_bench.py +899 -0
- bindsight/benchmark/outcomes.py +508 -0
- bindsight/benchmark/panel.py +621 -0
- bindsight/benchmark/rediscovery.py +1153 -0
- bindsight/benchmark/statistics.py +622 -0
- bindsight/benchmark/study.py +1153 -0
- bindsight/benchmark/study_figures.py +164 -0
- bindsight/benchmark/study_report.py +607 -0
- bindsight/cli.py +2429 -0
- bindsight/config.py +451 -0
- bindsight/cost.py +258 -0
- bindsight/deg/__init__.py +7 -0
- bindsight/deg/pydeseq2_runner.py +285 -0
- bindsight/design/__init__.py +23 -0
- bindsight/design/_common.py +501 -0
- bindsight/design/bindcraft.py +78 -0
- bindsight/design/boltzgen.py +79 -0
- bindsight/design/developability.py +140 -0
- bindsight/design/embeddings.py +165 -0
- bindsight/design/prescreen.py +169 -0
- bindsight/design/protocol.py +111 -0
- bindsight/design/rfdiff_mpnn.py +93 -0
- bindsight/epitopes/__init__.py +7 -0
- bindsight/epitopes/surface_bind.py +134 -0
- bindsight/export/__init__.py +7 -0
- bindsight/export/ro_crate.py +595 -0
- bindsight/io/__init__.py +19 -0
- bindsight/io/cbioportal.py +118 -0
- bindsight/io/gdc.py +343 -0
- bindsight/io/paths.py +124 -0
- bindsight/pipelines/__init__.py +9 -0
- bindsight/pipelines/caveats.py +60 -0
- bindsight/pipelines/discover.py +1443 -0
- bindsight/pipelines/full_run.py +410 -0
- bindsight/plugins.py +335 -0
- bindsight/provenance/__init__.py +34 -0
- bindsight/provenance/append.py +199 -0
- bindsight/provenance/fragments.py +216 -0
- bindsight/provenance/manifest.py +506 -0
- bindsight/rank/__init__.py +7 -0
- bindsight/rank/scoring.py +355 -0
- bindsight/report/__init__.py +39 -0
- bindsight/report/format.py +100 -0
- bindsight/report/html.py +645 -0
- bindsight/report/showcase.py +653 -0
- bindsight/report/templates/report.css +67 -0
- bindsight/report/templates/report.html.j2 +420 -0
- bindsight/report/theme.py +231 -0
- bindsight/report/web/__init__.py +35 -0
- bindsight/report/web/app.py +624 -0
- bindsight/report/web/static/binder_viewer.js +245 -0
- bindsight/report/web/static/bindsight.css +455 -0
- bindsight/report/web/static/charts.js +425 -0
- bindsight/report/web/static/vendor/3Dmol-min.js +2 -0
- bindsight/report/web/static/vendor/3Dmol.LICENSE +90 -0
- bindsight/report/web/static/your_data_check.js +370 -0
- bindsight/report/web/templates/base.html.j2 +48 -0
- bindsight/report/web/templates/evidence.html.j2 +341 -0
- bindsight/report/web/templates/overview.html.j2 +140 -0
- bindsight/report/web/templates/runs.html.j2 +125 -0
- bindsight/report/web/templates/try.html.j2 +155 -0
- bindsight/report/web/templates/your_data.html.j2 +46 -0
- bindsight/runners/__init__.py +7 -0
- bindsight/runners/colab.py +158 -0
- bindsight/runners/job_exec.py +1110 -0
- bindsight/runners/kaggle.py +425 -0
- bindsight/runners/kaggle_kernel.py +489 -0
- bindsight/runners/local_docker.py +154 -0
- bindsight/runners/mock.py +167 -0
- bindsight/runners/modal_runner.py +199 -0
- bindsight/runners/notebook.py +116 -0
- bindsight/runners/notebook_content.py +217 -0
- bindsight/runners/protocol.py +81 -0
- bindsight/runners/source_wheel.py +104 -0
- bindsight/runners/tools.py +997 -0
- bindsight/structures/__init__.py +7 -0
- bindsight/structures/alphafolddb.py +93 -0
- bindsight/structures/plddt.py +92 -0
- bindsight/structures/topology.py +227 -0
- bindsight/surfaceome/__init__.py +42 -0
- bindsight/surfaceome/data/surfaceome_extended.tsv +4814 -0
- bindsight/surfaceome/data/surfy_offline.txt +17 -0
- bindsight/surfaceome/data/surfy_v1.ensembl.tsv +3380 -0
- bindsight/surfaceome/data/surfy_v1.uniprot.txt +2901 -0
- bindsight/surfaceome/surfy.py +441 -0
- bindsight/targets/__init__.py +7 -0
- bindsight/targets/ensembl_uniprot.py +56 -0
- bindsight/targets/gtex.py +250 -0
- bindsight/targets/open_targets.py +239 -0
- bindsight/validate/__init__.py +7 -0
- bindsight/validate/af2_ig.py +49 -0
- bindsight/validate/boltz2.py +256 -0
- bindsight/validate/chai1r.py +42 -0
- bindsight/validate/protocol.py +188 -0
- bindsight-0.3.5.data/data/bindsight_demo/config.yaml +54 -0
- bindsight-0.3.5.dist-info/METADATA +495 -0
- bindsight-0.3.5.dist-info/RECORD +106 -0
- bindsight-0.3.5.dist-info/WHEEL +4 -0
- bindsight-0.3.5.dist-info/entry_points.txt +20 -0
- bindsight-0.3.5.dist-info/licenses/COPYRIGHT +24 -0
- bindsight-0.3.5.dist-info/licenses/LICENSE +681 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Kaggle Notebooks GPU runner.
|
|
4
|
+
|
|
5
|
+
Drives Kaggle's free GPU tier (T4×2, ~30 GPU-hr/week) headlessly via the Kaggle
|
|
6
|
+
public API: push a generated kernel that runs :mod:`bindsight.runners.job_exec`,
|
|
7
|
+
poll its status, then pull the results tarball from the kernel output. Requires
|
|
8
|
+
the ``runners`` extra (``kaggle``) and Kaggle API credentials
|
|
9
|
+
(``~/.kaggle/kaggle.json`` or ``KAGGLE_USERNAME``/``KAGGLE_KEY``).
|
|
10
|
+
|
|
11
|
+
The ``kaggle`` client is imported lazily so ``import bindsight`` works without
|
|
12
|
+
the extra installed.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import builtins
|
|
19
|
+
import contextlib
|
|
20
|
+
import gzip
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import time
|
|
24
|
+
import uuid
|
|
25
|
+
from collections.abc import Iterator
|
|
26
|
+
from datetime import UTC, datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from bindsight.cost import estimate
|
|
31
|
+
from bindsight.runners import kaggle_kernel
|
|
32
|
+
from bindsight.runners.protocol import CostEstimate, JobHandle, JobStatus
|
|
33
|
+
|
|
34
|
+
LOG = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
#: Ceiling on the generated kernel script, in bytes.
|
|
37
|
+
#:
|
|
38
|
+
#: Kaggle rejects an oversized kernel at push time, which is cheap, but the
|
|
39
|
+
#: error is opaque. Checking here names the two things that actually grow the
|
|
40
|
+
#: script: the embedded target structure and the embedded wheel.
|
|
41
|
+
_MAX_KERNEL_BYTES = 900_000
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
#: Consecutive failed polls tolerated before a wait is abandoned.
|
|
45
|
+
#:
|
|
46
|
+
#: At the default 30-second interval this rides out roughly ten minutes of
|
|
47
|
+
#: network trouble. The cost of being wrong in each direction is asymmetric: a
|
|
48
|
+
#: needless retry costs one HTTPS request, while giving up too early abandons a
|
|
49
|
+
#: kernel that keeps consuming a fixed weekly GPU quota with nothing waiting on
|
|
50
|
+
#: its result.
|
|
51
|
+
_POLL_FAILURE_LIMIT = 20
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class KaggleUnavailable(RuntimeError):
|
|
55
|
+
"""The Kaggle client is not installed — a condition no retry can heal."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _require_kaggle() -> Any:
|
|
59
|
+
"""Import + authenticate the Kaggle API, with a clear error if missing."""
|
|
60
|
+
try:
|
|
61
|
+
from kaggle.api.kaggle_api_extended import KaggleApi
|
|
62
|
+
except ImportError as e:
|
|
63
|
+
raise KaggleUnavailable(
|
|
64
|
+
'Kaggle runner needs the "runners" extra: pip install -e ".[runners]" '
|
|
65
|
+
"and Kaggle credentials (~/.kaggle/access_token or ~/.kaggle/kaggle.json)."
|
|
66
|
+
) from e
|
|
67
|
+
api = KaggleApi()
|
|
68
|
+
api.authenticate()
|
|
69
|
+
return api
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Kaggle's status enum (KernelWorkerStatus.*) → our JobStatus.state. The API
|
|
73
|
+
# returns an object whose ``.status`` is the enum (not a lowercase dict), so we
|
|
74
|
+
# read ``.name`` and map it here.
|
|
75
|
+
_STATE_MAP = {
|
|
76
|
+
"QUEUED": "queued",
|
|
77
|
+
"RUNNING": "running",
|
|
78
|
+
"COMPLETE": "succeeded",
|
|
79
|
+
"ERROR": "failed",
|
|
80
|
+
"CANCEL_REQUESTED": "running",
|
|
81
|
+
"CANCEL_ACKNOWLEDGED": "cancelled",
|
|
82
|
+
"CANCELACKNOWLEDGED": "cancelled",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _status_name(status: Any) -> str:
|
|
87
|
+
"""Extract the upper-case status name from a kernels_status response."""
|
|
88
|
+
s = getattr(status, "status", status)
|
|
89
|
+
name = getattr(s, "name", None)
|
|
90
|
+
if name:
|
|
91
|
+
return str(name).upper()
|
|
92
|
+
if isinstance(status, dict):
|
|
93
|
+
return str(status.get("status", "")).upper()
|
|
94
|
+
return str(s).upper()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@contextlib.contextmanager
|
|
98
|
+
def _default_text_encoding(encoding: str) -> Iterator[None]:
|
|
99
|
+
"""Make encoding-less text ``open()`` calls use ``encoding`` inside this block.
|
|
100
|
+
|
|
101
|
+
The Kaggle client writes the kernel log with a plain ``open(path, "w")``, so
|
|
102
|
+
it inherits the interpreter's locale encoding. Kernel output carries
|
|
103
|
+
micromamba's progress glyphs (U+29D6, U+2714), which cp1252 cannot represent,
|
|
104
|
+
so on a default Windows install the write raises — discarding a completed GPU
|
|
105
|
+
run over a log file.
|
|
106
|
+
|
|
107
|
+
Running the whole interpreter with ``PYTHONUTF8=1`` fixes it, but that is a
|
|
108
|
+
startup setting and cannot be applied to a library call. Narrowing the
|
|
109
|
+
default for the duration of one download can.
|
|
110
|
+
|
|
111
|
+
Only calls that pass no ``encoding`` and no ``mode`` containing ``b`` are
|
|
112
|
+
affected; binary reads and writes, including the results tarball, go through
|
|
113
|
+
untouched.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
encoding: the encoding to use when a caller specifies none.
|
|
117
|
+
|
|
118
|
+
Yields:
|
|
119
|
+
None, for the duration of the override.
|
|
120
|
+
"""
|
|
121
|
+
real_open = builtins.open
|
|
122
|
+
|
|
123
|
+
def _open(file: Any, mode: str = "r", *args: Any, **kwargs: Any) -> Any:
|
|
124
|
+
if "b" not in mode and "encoding" not in kwargs:
|
|
125
|
+
kwargs["encoding"] = encoding
|
|
126
|
+
kwargs.setdefault("errors", "replace")
|
|
127
|
+
return real_open(file, mode, *args, **kwargs)
|
|
128
|
+
|
|
129
|
+
builtins.open = _open
|
|
130
|
+
try:
|
|
131
|
+
yield
|
|
132
|
+
finally:
|
|
133
|
+
builtins.open = real_open
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class KaggleRunner:
|
|
137
|
+
"""Headless Kaggle Notebooks runner (push kernel, poll, pull tarball)."""
|
|
138
|
+
|
|
139
|
+
name = "kaggle"
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
*,
|
|
144
|
+
designer: str = "rfdiff_mpnn",
|
|
145
|
+
gpu_type: str = kaggle_kernel.KAGGLE_COST_GPU,
|
|
146
|
+
username: str | None = None,
|
|
147
|
+
poll_interval_s: int = 30,
|
|
148
|
+
bindsight_ref: str | None = None,
|
|
149
|
+
bindsight_wheel: Path | str | None = None,
|
|
150
|
+
) -> None:
|
|
151
|
+
self.designer = designer
|
|
152
|
+
self.gpu_type = gpu_type
|
|
153
|
+
self.username = username
|
|
154
|
+
self.poll_interval_s = poll_interval_s
|
|
155
|
+
# Git ref the kernel installs bindsight from (default branch if None).
|
|
156
|
+
self.bindsight_ref = bindsight_ref
|
|
157
|
+
# A wheel built from the working tree. When set it wins over the ref,
|
|
158
|
+
# because it is the code that launched the run rather than whatever the
|
|
159
|
+
# branch resolves to when pip runs on the GPU.
|
|
160
|
+
self.bindsight_wheel = Path(bindsight_wheel) if bindsight_wheel else None
|
|
161
|
+
# Authenticated client, created on first use. See :meth:`_api`.
|
|
162
|
+
self._api_client: Any = None
|
|
163
|
+
|
|
164
|
+
def _api(self) -> Any:
|
|
165
|
+
"""Return the authenticated Kaggle client, authenticating once.
|
|
166
|
+
|
|
167
|
+
``authenticate()`` is not local: it introspects the access token over
|
|
168
|
+
HTTPS. Calling it on every poll made a job's whole lifetime a chain of
|
|
169
|
+
network round trips, any one of which could end the job — a single
|
|
170
|
+
dropped TLS handshake mid-poll raised ``SSLError`` out of :meth:`fetch`
|
|
171
|
+
and abandoned a kernel that went on to finish on Kaggle with nobody
|
|
172
|
+
waiting for its output. Authenticating once removes both the repeated
|
|
173
|
+
cost and that failure mode.
|
|
174
|
+
"""
|
|
175
|
+
if self._api_client is None:
|
|
176
|
+
self._api_client = _require_kaggle()
|
|
177
|
+
return self._api_client
|
|
178
|
+
|
|
179
|
+
def _reset_api(self) -> None:
|
|
180
|
+
"""Drop the cached client so the next call re-authenticates.
|
|
181
|
+
|
|
182
|
+
Used after a failed poll: if the failure was a stale or revoked session
|
|
183
|
+
rather than a transient blip, reauthenticating is what recovers it.
|
|
184
|
+
"""
|
|
185
|
+
self._api_client = None
|
|
186
|
+
|
|
187
|
+
def estimate_cost(self, spec_size: int) -> CostEstimate:
|
|
188
|
+
"""Estimate cost (free tier — $0, but queue/quota limited)."""
|
|
189
|
+
return estimate(
|
|
190
|
+
backend=self.name,
|
|
191
|
+
stage="design",
|
|
192
|
+
plugin=self.designer,
|
|
193
|
+
n_units=spec_size,
|
|
194
|
+
gpu_type=self.gpu_type,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def submit(self, spec_path: Path, *, results_dir: Path) -> JobHandle:
|
|
198
|
+
"""Push a self-contained Kaggle kernel that runs the executor on the spec.
|
|
199
|
+
|
|
200
|
+
The spec dir (``spec.json`` + the target structure it references) is
|
|
201
|
+
embedded in the kernel as base64 — no Kaggle dataset needed — and the
|
|
202
|
+
kernel builds RFdiffusion's legacy env + the Boltz-2 env on the GPU before
|
|
203
|
+
running :mod:`bindsight.runners.job_exec` across them.
|
|
204
|
+
"""
|
|
205
|
+
api = self._api()
|
|
206
|
+
results_dir.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
handle_id = uuid.uuid4().hex[:12]
|
|
208
|
+
user = self.username or api.config_values.get("username", "user")
|
|
209
|
+
slug = f"bindsight-{handle_id}"
|
|
210
|
+
|
|
211
|
+
# Embed every file in the spec dir (spec.json + target structure),
|
|
212
|
+
# gzipped. Structures are mmCIF or PDB text and compress to roughly a
|
|
213
|
+
# fifth: a full-length receptor is about 550 KB of base64 raw and 120 KB
|
|
214
|
+
# compressed. Without this, that structure plus the working-tree wheel
|
|
215
|
+
# pushes the kernel script past its size ceiling, and the run cannot be
|
|
216
|
+
# submitted at all.
|
|
217
|
+
# Walked recursively, keyed by path relative to the spec dir. This used
|
|
218
|
+
# to be `iterdir()` filtered to files, which silently skipped the
|
|
219
|
+
# `design/` subdirectory `submit_via_runner` fills for a validate-only
|
|
220
|
+
# job — so every re-scoring run on this backend shipped the spec and the
|
|
221
|
+
# target and none of the binders it was asked to score, and found out on
|
|
222
|
+
# the GPU.
|
|
223
|
+
spec_root = spec_path.parent
|
|
224
|
+
payload = {
|
|
225
|
+
f.relative_to(spec_root).as_posix(): base64.b64encode(
|
|
226
|
+
gzip.compress(f.read_bytes(), 9)
|
|
227
|
+
).decode("ascii")
|
|
228
|
+
for f in sorted(spec_root.rglob("*"))
|
|
229
|
+
if f.is_file()
|
|
230
|
+
}
|
|
231
|
+
wheel_b64: str | None = None
|
|
232
|
+
if self.bindsight_wheel is not None:
|
|
233
|
+
if not self.bindsight_wheel.is_file():
|
|
234
|
+
raise FileNotFoundError(f"bindsight wheel not found: {self.bindsight_wheel}")
|
|
235
|
+
wheel_b64 = base64.b64encode(self.bindsight_wheel.read_bytes()).decode("ascii")
|
|
236
|
+
LOG.info(
|
|
237
|
+
"kaggle: embedding %s (%d bytes) — the kernel installs this, not a git ref",
|
|
238
|
+
self.bindsight_wheel.name,
|
|
239
|
+
self.bindsight_wheel.stat().st_size,
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
LOG.warning(
|
|
243
|
+
"kaggle: no wheel given, so the kernel will pip-install bindsight from "
|
|
244
|
+
"%s. Local changes that are not on that ref will NOT run on the GPU. "
|
|
245
|
+
"Pass bindsight_wheel= to run the working tree.",
|
|
246
|
+
self.bindsight_ref or "the default branch",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Which kind of job this is lives in the spec and nowhere else. A
|
|
250
|
+
# validate-only run re-scores binders that already exist, so the kernel
|
|
251
|
+
# must not build the designer's environment: doing so spent roughly six
|
|
252
|
+
# minutes and several gigabytes of a weekly quota on tools it never
|
|
253
|
+
# invoked.
|
|
254
|
+
try:
|
|
255
|
+
spec_mode = str(
|
|
256
|
+
(json.loads(spec_path.read_text(encoding="utf-8")).get("extra_params") or {}).get(
|
|
257
|
+
"mode"
|
|
258
|
+
)
|
|
259
|
+
or "design_and_validate"
|
|
260
|
+
)
|
|
261
|
+
except (OSError, ValueError) as e: # a spec we cannot read is not a mode claim
|
|
262
|
+
LOG.warning(
|
|
263
|
+
"could not read the job mode from %s (%s); building the full environment",
|
|
264
|
+
spec_path,
|
|
265
|
+
e,
|
|
266
|
+
)
|
|
267
|
+
spec_mode = "design_and_validate"
|
|
268
|
+
|
|
269
|
+
script = kaggle_kernel.build_kernel_script(
|
|
270
|
+
handle_id=handle_id,
|
|
271
|
+
payload=payload,
|
|
272
|
+
mode=spec_mode,
|
|
273
|
+
bindsight_ref=self.bindsight_ref,
|
|
274
|
+
bindsight_wheel_b64=wheel_b64,
|
|
275
|
+
bindsight_wheel_name=(
|
|
276
|
+
self.bindsight_wheel.name
|
|
277
|
+
if self.bindsight_wheel is not None
|
|
278
|
+
else "bindsight-0.0.0-py3-none-any.whl"
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
if len(script.encode("utf-8")) > _MAX_KERNEL_BYTES:
|
|
282
|
+
raise ValueError(
|
|
283
|
+
f"kernel script is {len(script.encode('utf-8')):,} bytes, over the "
|
|
284
|
+
f"{_MAX_KERNEL_BYTES:,}-byte ceiling. The embedded wheel and target "
|
|
285
|
+
"structure are what grow it. Push the branch and pass bindsight_ref "
|
|
286
|
+
"instead of a wheel, or reduce the payload."
|
|
287
|
+
)
|
|
288
|
+
# Only now, once the script is known to be submittable: a refused
|
|
289
|
+
# submit should leave no half-built kernel directory behind.
|
|
290
|
+
work = results_dir / f"kaggle_{handle_id}"
|
|
291
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
292
|
+
(work / "kernel.py").write_text(script, encoding="utf-8", newline="\n")
|
|
293
|
+
(work / "kernel-metadata.json").write_text(
|
|
294
|
+
json.dumps(kaggle_kernel.build_kernel_metadata(username=user, slug=slug)),
|
|
295
|
+
encoding="utf-8",
|
|
296
|
+
newline="\n",
|
|
297
|
+
)
|
|
298
|
+
api.kernels_push(str(work))
|
|
299
|
+
LOG.info("kaggle: pushed kernel %s/%s", user, slug)
|
|
300
|
+
return JobHandle(
|
|
301
|
+
backend=self.name,
|
|
302
|
+
id=f"{user}/{slug}",
|
|
303
|
+
submitted_at=datetime.now(UTC).isoformat(timespec="seconds"),
|
|
304
|
+
results_dir=str(results_dir),
|
|
305
|
+
handle_id=handle_id,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def poll(self, handle: JobHandle) -> JobStatus:
|
|
309
|
+
"""Query the kernel's run status."""
|
|
310
|
+
api = self._api()
|
|
311
|
+
status = api.kernels_status(handle.id)
|
|
312
|
+
name = _status_name(status)
|
|
313
|
+
return JobStatus(handle=handle, state=_STATE_MAP.get(name, "running"), log_tail=name)
|
|
314
|
+
|
|
315
|
+
def _await_terminal(self, handle: JobHandle) -> JobStatus:
|
|
316
|
+
"""Poll until the kernel reaches a terminal state, surviving network blips.
|
|
317
|
+
|
|
318
|
+
A poll is a network call, and networks fail transiently. Treating one
|
|
319
|
+
such failure as fatal is wrong twice over: the kernel keeps running on
|
|
320
|
+
Kaggle with nothing waiting for it, and the GPU hours it burns come out
|
|
321
|
+
of a fixed weekly quota that cannot be refunded. This is not
|
|
322
|
+
hypothetical — a dropped TLS handshake during a routine poll ended the
|
|
323
|
+
client for a design job that was two and a half hours into a T4.
|
|
324
|
+
|
|
325
|
+
So a failed poll means "unknown, ask again". Only a sustained outage of
|
|
326
|
+
``_POLL_FAILURE_LIMIT`` consecutive failures ends the wait, and the last
|
|
327
|
+
error is chained so the cause survives. A missing Kaggle client is
|
|
328
|
+
re-raised at once, because no amount of retrying installs a package.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
handle: the job to wait on.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
The terminal :class:`JobStatus` (succeeded, failed or cancelled).
|
|
335
|
+
|
|
336
|
+
Raises:
|
|
337
|
+
KaggleUnavailable: the Kaggle client is not installed.
|
|
338
|
+
RuntimeError: polling failed ``_POLL_FAILURE_LIMIT`` times running.
|
|
339
|
+
"""
|
|
340
|
+
failures = 0
|
|
341
|
+
while True:
|
|
342
|
+
try:
|
|
343
|
+
st = self.poll(handle)
|
|
344
|
+
except KaggleUnavailable:
|
|
345
|
+
raise
|
|
346
|
+
except Exception as e:
|
|
347
|
+
failures += 1
|
|
348
|
+
if failures >= _POLL_FAILURE_LIMIT:
|
|
349
|
+
raise RuntimeError(
|
|
350
|
+
f"kaggle kernel {handle.id}: {failures} consecutive poll "
|
|
351
|
+
"failures. The kernel may still be running on Kaggle; "
|
|
352
|
+
"rerun to reattach to it rather than resubmitting."
|
|
353
|
+
) from e
|
|
354
|
+
LOG.warning(
|
|
355
|
+
"kaggle: poll %d/%d for %s failed (%s); retrying in %ds",
|
|
356
|
+
failures,
|
|
357
|
+
_POLL_FAILURE_LIMIT,
|
|
358
|
+
handle.id,
|
|
359
|
+
e,
|
|
360
|
+
self.poll_interval_s,
|
|
361
|
+
)
|
|
362
|
+
self._reset_api()
|
|
363
|
+
time.sleep(self.poll_interval_s)
|
|
364
|
+
continue
|
|
365
|
+
failures = 0
|
|
366
|
+
if st.state in {"succeeded", "failed", "cancelled"}:
|
|
367
|
+
return st
|
|
368
|
+
time.sleep(self.poll_interval_s)
|
|
369
|
+
|
|
370
|
+
def fetch(self, handle: JobHandle) -> Path:
|
|
371
|
+
"""Block until the kernel completes; download the results tarball."""
|
|
372
|
+
api = self._api()
|
|
373
|
+
results_dir = Path(getattr(handle, "results_dir", "."))
|
|
374
|
+
handle_id = getattr(handle, "handle_id", "")
|
|
375
|
+
st = self._await_terminal(handle)
|
|
376
|
+
if st.state != "succeeded":
|
|
377
|
+
raise RuntimeError(f"kaggle kernel {handle.id} finished in state {st.state}")
|
|
378
|
+
self._download_output(api, handle.id, results_dir)
|
|
379
|
+
tarball = results_dir / f"{handle_id}.tar.gz"
|
|
380
|
+
if not tarball.exists():
|
|
381
|
+
raise RuntimeError(f"kaggle kernel {handle.id} produced no tarball at {tarball}")
|
|
382
|
+
return tarball
|
|
383
|
+
|
|
384
|
+
@staticmethod
|
|
385
|
+
def _download_output(api: Any, kernel_id: str, results_dir: Path) -> None:
|
|
386
|
+
"""Download a finished kernel's output, surviving an unwritable log.
|
|
387
|
+
|
|
388
|
+
The Kaggle client writes the kernel log with the interpreter's default
|
|
389
|
+
text encoding. Kernel output contains micromamba's progress glyphs
|
|
390
|
+
(U+29D6, U+2714), so on a cp1252 Windows install the write raises
|
|
391
|
+
``UnicodeEncodeError`` — and it raises *after* the GPU work is finished,
|
|
392
|
+
which turns a completed hour of a weekly 30-hour quota into a run with
|
|
393
|
+
no results. That is exactly what happened to the first corrected
|
|
394
|
+
designer benchmark: the T4 ran to completion, the tarball was produced,
|
|
395
|
+
and the harness recorded zero designs and an encoding error.
|
|
396
|
+
|
|
397
|
+
The log is a convenience; the tarball is the result. So a log failure is
|
|
398
|
+
downgraded to a warning and the download is retried once. The retry
|
|
399
|
+
succeeds because the client skips files that already exist, and the
|
|
400
|
+
truncated log file does, so the second pass reaches the tarball.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
api: the authenticated Kaggle API client.
|
|
404
|
+
kernel_id: ``owner/slug`` of the finished kernel.
|
|
405
|
+
results_dir: directory to download into.
|
|
406
|
+
"""
|
|
407
|
+
with _default_text_encoding("utf-8"):
|
|
408
|
+
try:
|
|
409
|
+
api.kernels_output(kernel_id, str(results_dir))
|
|
410
|
+
return
|
|
411
|
+
except UnicodeEncodeError as e:
|
|
412
|
+
LOG.warning(
|
|
413
|
+
"kaggle: could not write the kernel log on this platform (%s). "
|
|
414
|
+
"The log is not the result; retrying for the tarball.",
|
|
415
|
+
e,
|
|
416
|
+
)
|
|
417
|
+
# Second pass. Anything already written is skipped, so this picks up
|
|
418
|
+
# where the encoding error interrupted.
|
|
419
|
+
try:
|
|
420
|
+
api.kernels_output(kernel_id, str(results_dir))
|
|
421
|
+
except UnicodeEncodeError as e:
|
|
422
|
+
LOG.warning("kaggle: log still unwritable (%s); checking for the tarball anyway", e)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
__all__ = ["KaggleRunner"]
|