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.
Files changed (106) hide show
  1. bindsight/README.md +44 -0
  2. bindsight/__init__.py +15 -0
  3. bindsight/benchmark/__init__.py +71 -0
  4. bindsight/benchmark/core.py +497 -0
  5. bindsight/benchmark/defaults.py +28 -0
  6. bindsight/benchmark/designer_bench.py +899 -0
  7. bindsight/benchmark/outcomes.py +508 -0
  8. bindsight/benchmark/panel.py +621 -0
  9. bindsight/benchmark/rediscovery.py +1153 -0
  10. bindsight/benchmark/statistics.py +622 -0
  11. bindsight/benchmark/study.py +1153 -0
  12. bindsight/benchmark/study_figures.py +164 -0
  13. bindsight/benchmark/study_report.py +607 -0
  14. bindsight/cli.py +2429 -0
  15. bindsight/config.py +451 -0
  16. bindsight/cost.py +258 -0
  17. bindsight/deg/__init__.py +7 -0
  18. bindsight/deg/pydeseq2_runner.py +285 -0
  19. bindsight/design/__init__.py +23 -0
  20. bindsight/design/_common.py +501 -0
  21. bindsight/design/bindcraft.py +78 -0
  22. bindsight/design/boltzgen.py +79 -0
  23. bindsight/design/developability.py +140 -0
  24. bindsight/design/embeddings.py +165 -0
  25. bindsight/design/prescreen.py +169 -0
  26. bindsight/design/protocol.py +111 -0
  27. bindsight/design/rfdiff_mpnn.py +93 -0
  28. bindsight/epitopes/__init__.py +7 -0
  29. bindsight/epitopes/surface_bind.py +134 -0
  30. bindsight/export/__init__.py +7 -0
  31. bindsight/export/ro_crate.py +595 -0
  32. bindsight/io/__init__.py +19 -0
  33. bindsight/io/cbioportal.py +118 -0
  34. bindsight/io/gdc.py +343 -0
  35. bindsight/io/paths.py +124 -0
  36. bindsight/pipelines/__init__.py +9 -0
  37. bindsight/pipelines/caveats.py +60 -0
  38. bindsight/pipelines/discover.py +1443 -0
  39. bindsight/pipelines/full_run.py +410 -0
  40. bindsight/plugins.py +335 -0
  41. bindsight/provenance/__init__.py +34 -0
  42. bindsight/provenance/append.py +199 -0
  43. bindsight/provenance/fragments.py +216 -0
  44. bindsight/provenance/manifest.py +506 -0
  45. bindsight/rank/__init__.py +7 -0
  46. bindsight/rank/scoring.py +355 -0
  47. bindsight/report/__init__.py +39 -0
  48. bindsight/report/format.py +100 -0
  49. bindsight/report/html.py +645 -0
  50. bindsight/report/showcase.py +653 -0
  51. bindsight/report/templates/report.css +67 -0
  52. bindsight/report/templates/report.html.j2 +420 -0
  53. bindsight/report/theme.py +231 -0
  54. bindsight/report/web/__init__.py +35 -0
  55. bindsight/report/web/app.py +624 -0
  56. bindsight/report/web/static/binder_viewer.js +245 -0
  57. bindsight/report/web/static/bindsight.css +455 -0
  58. bindsight/report/web/static/charts.js +425 -0
  59. bindsight/report/web/static/vendor/3Dmol-min.js +2 -0
  60. bindsight/report/web/static/vendor/3Dmol.LICENSE +90 -0
  61. bindsight/report/web/static/your_data_check.js +370 -0
  62. bindsight/report/web/templates/base.html.j2 +48 -0
  63. bindsight/report/web/templates/evidence.html.j2 +341 -0
  64. bindsight/report/web/templates/overview.html.j2 +140 -0
  65. bindsight/report/web/templates/runs.html.j2 +125 -0
  66. bindsight/report/web/templates/try.html.j2 +155 -0
  67. bindsight/report/web/templates/your_data.html.j2 +46 -0
  68. bindsight/runners/__init__.py +7 -0
  69. bindsight/runners/colab.py +158 -0
  70. bindsight/runners/job_exec.py +1110 -0
  71. bindsight/runners/kaggle.py +425 -0
  72. bindsight/runners/kaggle_kernel.py +489 -0
  73. bindsight/runners/local_docker.py +154 -0
  74. bindsight/runners/mock.py +167 -0
  75. bindsight/runners/modal_runner.py +199 -0
  76. bindsight/runners/notebook.py +116 -0
  77. bindsight/runners/notebook_content.py +217 -0
  78. bindsight/runners/protocol.py +81 -0
  79. bindsight/runners/source_wheel.py +104 -0
  80. bindsight/runners/tools.py +997 -0
  81. bindsight/structures/__init__.py +7 -0
  82. bindsight/structures/alphafolddb.py +93 -0
  83. bindsight/structures/plddt.py +92 -0
  84. bindsight/structures/topology.py +227 -0
  85. bindsight/surfaceome/__init__.py +42 -0
  86. bindsight/surfaceome/data/surfaceome_extended.tsv +4814 -0
  87. bindsight/surfaceome/data/surfy_offline.txt +17 -0
  88. bindsight/surfaceome/data/surfy_v1.ensembl.tsv +3380 -0
  89. bindsight/surfaceome/data/surfy_v1.uniprot.txt +2901 -0
  90. bindsight/surfaceome/surfy.py +441 -0
  91. bindsight/targets/__init__.py +7 -0
  92. bindsight/targets/ensembl_uniprot.py +56 -0
  93. bindsight/targets/gtex.py +250 -0
  94. bindsight/targets/open_targets.py +239 -0
  95. bindsight/validate/__init__.py +7 -0
  96. bindsight/validate/af2_ig.py +49 -0
  97. bindsight/validate/boltz2.py +256 -0
  98. bindsight/validate/chai1r.py +42 -0
  99. bindsight/validate/protocol.py +188 -0
  100. bindsight-0.3.5.data/data/bindsight_demo/config.yaml +54 -0
  101. bindsight-0.3.5.dist-info/METADATA +495 -0
  102. bindsight-0.3.5.dist-info/RECORD +106 -0
  103. bindsight-0.3.5.dist-info/WHEEL +4 -0
  104. bindsight-0.3.5.dist-info/entry_points.txt +20 -0
  105. bindsight-0.3.5.dist-info/licenses/COPYRIGHT +24 -0
  106. 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"]