letify 2.2.0__tar.gz → 2.4.0__tar.gz
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.
- {letify-2.2.0 → letify-2.4.0}/PKG-INFO +1 -1
- {letify-2.2.0 → letify-2.4.0}/letify/__init__.py +1 -1
- {letify-2.2.0 → letify-2.4.0}/letify/launcher.py +1 -1
- {letify-2.2.0 → letify-2.4.0}/letify/providers/base.py +49 -0
- {letify-2.2.0 → letify-2.4.0}/letify/providers/colab.py +2 -0
- {letify-2.2.0 → letify-2.4.0}/letify/providers/kaggle.py +170 -5
- {letify-2.2.0 → letify-2.4.0}/letify/runtime/bootstrap.py +27 -0
- {letify-2.2.0 → letify-2.4.0}/letify/runtime/session.py +38 -0
- {letify-2.2.0 → letify-2.4.0}/letify/transport/pipeline.py +44 -0
- {letify-2.2.0 → letify-2.4.0}/letify-core/Cargo.lock +3 -3
- {letify-2.2.0 → letify-2.4.0}/letify-core/Cargo.toml +1 -1
- {letify-2.2.0 → letify-2.4.0}/letify-ext/package-lock.json +2 -2
- {letify-2.2.0 → letify-2.4.0}/letify-ext/package.json +1 -1
- {letify-2.2.0 → letify-2.4.0}/pyproject.toml +1 -1
- {letify-2.2.0 → letify-2.4.0}/tests/conftest.py +11 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_elice.py +5 -1
- {letify-2.2.0 → letify-2.4.0}/tests/test_kaggle.py +174 -4
- {letify-2.2.0 → letify-2.4.0}/tests/test_kaggle_link.py +18 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_runtime.py +32 -1
- {letify-2.2.0 → letify-2.4.0}/tests/test_transport.py +32 -0
- {letify-2.2.0 → letify-2.4.0}/.github/workflows/ci.yml +0 -0
- {letify-2.2.0 → letify-2.4.0}/.github/workflows/docs.yml +0 -0
- {letify-2.2.0 → letify-2.4.0}/.github/workflows/publish.yml +0 -0
- {letify-2.2.0 → letify-2.4.0}/.gitignore +0 -0
- {letify-2.2.0 → letify-2.4.0}/.researchtree +0 -0
- {letify-2.2.0 → letify-2.4.0}/LICENSE +0 -0
- {letify-2.2.0 → letify-2.4.0}/README.md +0 -0
- {letify-2.2.0 → letify-2.4.0}/docs/guide/01-getting-started.md +0 -0
- {letify-2.2.0 → letify-2.4.0}/docs/guide/02-providers.md +0 -0
- {letify-2.2.0 → letify-2.4.0}/docs/guide/03-execution-modes.md +0 -0
- {letify-2.2.0 → letify-2.4.0}/docs/guide/04-environments-and-data.md +0 -0
- {letify-2.2.0 → letify-2.4.0}/docs/guide/05-concurrency.md +0 -0
- {letify-2.2.0 → letify-2.4.0}/docs/guide/06-cost.md +0 -0
- {letify-2.2.0 → letify-2.4.0}/docs/guide/07-troubleshooting.md +0 -0
- {letify-2.2.0 → letify-2.4.0}/docs/guide/08-releasing.md +0 -0
- {letify-2.2.0 → letify-2.4.0}/docs/guide/README.md +0 -0
- {letify-2.2.0 → letify-2.4.0}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
- {letify-2.2.0 → letify-2.4.0}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
- {letify-2.2.0 → letify-2.4.0}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
- {letify-2.2.0 → letify-2.4.0}/docs/guide/ko/04-/355/231/230/352/262/275/352/263/274-/353/215/260/354/235/264/355/204/260.md" +0 -0
- {letify-2.2.0 → letify-2.4.0}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
- {letify-2.2.0 → letify-2.4.0}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-2.2.0 → letify-2.4.0}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
- {letify-2.2.0 → letify-2.4.0}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-2.2.0 → letify-2.4.0}/docs/guide/ko/README.md +0 -0
- {letify-2.2.0 → letify-2.4.0}/docs/locales/README_ko.md +0 -0
- {letify-2.2.0 → letify-2.4.0}/docs/mkdocs.yml +0 -0
- {letify-2.2.0 → letify-2.4.0}/examples/README.md +0 -0
- {letify-2.2.0 → letify-2.4.0}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-2.2.0 → letify-2.4.0}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/examples/nvfp4-lora/README.md +0 -0
- {letify-2.2.0 → letify-2.4.0}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-2.2.0 → letify-2.4.0}/examples/nvfp4-lora/common.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/cli.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/config/__init__.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/config/inventory.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/config/login.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/config/schema.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/config/secrets.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/config/writer.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/declare/__init__.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/declare/cache.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/declare/env.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/declare/function.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/declare/instance.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/errors.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/install.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/paths.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/protocol/__init__.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/protocol/codec.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/protocol/driver.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/protocol/handle.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/protocol/wire.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/protocol/worker.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/providers/__init__.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/providers/colab_files.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/providers/colab_keepalive.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/providers/elice.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/providers/kaggle_adapter.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/providers/local.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/providers/modal.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/providers/modal_adapter.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/providers/naming.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/providers/shell.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/providers/tunnel.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/providers/usage.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/remoting/__init__.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/remoting/capability.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/remoting/device/__init__.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/remoting/device/client.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/remoting/device/cuda.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/remoting/device/executor.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/remoting/device/frames.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/remoting/device/guard.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/remoting/device/tensor.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/remoting/device/trace.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/remoting/device/value.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/remoting/loader.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/remoting/probe.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/render.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/runtime/__init__.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/runtime/channel.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/runtime/lease.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/runtime/pool.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/runtime/telemetry.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/store/__init__.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/store/backends/__init__.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/store/backends/filesystem.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/store/backends/google_auth.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/store/backends/layout.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/store/backends/objects.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/store/cas.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/store/pathdata.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/store/sendorder.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/store/volume.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/stubs.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/tools.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/transport/__init__.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/transport/agent.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/transport/announce.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/transport/link.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/transport/nat.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/transport/portmap.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/transport/probe.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/transport/quic.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/transport/rendezvous.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/transport/setup.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/transport/sshopts.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify/transport/strategies.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-core/.gitignore +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-core/build.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-core/letify-quic/Cargo.toml +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-core/letify-quic/src/main.rs +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-core/rust-toolchain.toml +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-ext/.vscodeignore +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-ext/LICENSE +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-ext/README.md +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-ext/media/icon.svg +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-ext/src/cli.ts +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-ext/src/extension.ts +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-ext/src/history.ts +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-ext/src/model.ts +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-ext/src/poll.ts +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-ext/src/render.ts +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-ext/test/cli.test.ts +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-ext/test/manifest.test.ts +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-ext/test/model.test.ts +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-ext/test/regressions.test.ts +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify-ext/tsconfig.json +0 -0
- {letify-2.2.0 → letify-2.4.0}/letify_providers.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/scripts/sync-main.sh +0 -0
- {letify-2.2.0 → letify-2.4.0}/scripts/version.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/fake_kaggle_adapter.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/fake_modal_adapter.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_cli.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_cli_output.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_colab_keepalive.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_config.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_core.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_data.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_data_streaming.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_declare.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_device.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_editor_sessions.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_frames.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_install.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_kaggle_cookie.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_link_log.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_notebook.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_parallel_placement.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_paths.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_portmap.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_protocol.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_providers.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_quic.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_remoting.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_render.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_ship.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_store.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_strategies.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_stubs.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_tunnel_setup.py +0 -0
- {letify-2.2.0 → letify-2.4.0}/tests/test_version.py +0 -0
|
@@ -354,10 +354,59 @@ class Provider(abc.ABC):
|
|
|
354
354
|
return table[name]
|
|
355
355
|
if name.upper() in table:
|
|
356
356
|
return table[name.upper()]
|
|
357
|
+
retired = self.retired_reason(name)
|
|
358
|
+
if retired:
|
|
359
|
+
raise UnknownInstance(f"{self.alias} does not offer {name!r}. {retired}")
|
|
357
360
|
raise UnknownInstance(
|
|
358
361
|
f"{self.alias} does not offer {name!r}. Available: {', '.join(sorted(table))}"
|
|
359
362
|
)
|
|
360
363
|
|
|
364
|
+
#: Whether this provider can be asked what sessions the account already holds. A
|
|
365
|
+
#: launcher reads it rather than naming provider kinds. Spec "Kaggle", finding the
|
|
366
|
+
#: notebooks letify owns.
|
|
367
|
+
discovers_sessions: bool = False
|
|
368
|
+
|
|
369
|
+
def sessions(self) -> list[str]:
|
|
370
|
+
"""What the account already holds, for a provider that can be asked.
|
|
371
|
+
|
|
372
|
+
Empty for one that cannot, and empty rather than raising when the answer cannot be
|
|
373
|
+
read: a command asking what exists should not fail because nothing answered.
|
|
374
|
+
"""
|
|
375
|
+
return []
|
|
376
|
+
|
|
377
|
+
def expected_cards(self, instance: Any) -> tuple[str, int] | None:
|
|
378
|
+
"""The card name fragment and count a session for ``instance`` must have, or None.
|
|
379
|
+
|
|
380
|
+
None means the provider gives what it is asked for, so there is nothing to check.
|
|
381
|
+
A provider whose service may answer with another card overrides it. Spec "Kaggle",
|
|
382
|
+
the accelerators.
|
|
383
|
+
"""
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
@property
|
|
387
|
+
def fallback_mib_per_s(self) -> float:
|
|
388
|
+
"""What this provider's own fallback carries, or 0 when it is not known.
|
|
389
|
+
|
|
390
|
+
Spec "A floor rejection never picks something slower": a link the floor rejected
|
|
391
|
+
is kept when it beats this, because refusing a slow link only makes sense when
|
|
392
|
+
what happens instead is better.
|
|
393
|
+
"""
|
|
394
|
+
value = self.config.option("fallback_mib_per_s")
|
|
395
|
+
return max(0.0, float(value)) if isinstance(value, (int, float)) else 0.0
|
|
396
|
+
|
|
397
|
+
@property
|
|
398
|
+
def any_accelerator(self) -> bool:
|
|
399
|
+
"""Whether a card other than the one asked for is accepted, from ``any_accelerator``."""
|
|
400
|
+
return self.config.option("any_accelerator") is True
|
|
401
|
+
|
|
402
|
+
def retired_reason(self, name: str) -> str | None:
|
|
403
|
+
"""Why an accelerator this provider once offered is gone, or None.
|
|
404
|
+
|
|
405
|
+
A name the provider's service answers with a different card belongs here rather
|
|
406
|
+
than in the inventory, because a declaration states where a function runs.
|
|
407
|
+
"""
|
|
408
|
+
return None
|
|
409
|
+
|
|
361
410
|
def device(self, name: str) -> Instance:
|
|
362
411
|
"""Look up an accelerator by name."""
|
|
363
412
|
return getattr(self, name)
|
|
@@ -32,6 +32,7 @@ import urllib.parse
|
|
|
32
32
|
import urllib.request
|
|
33
33
|
from collections.abc import Mapping
|
|
34
34
|
from datetime import UTC, datetime
|
|
35
|
+
from pathlib import Path
|
|
35
36
|
from typing import TYPE_CHECKING, Any
|
|
36
37
|
|
|
37
38
|
from .. import tools
|
|
@@ -67,13 +68,20 @@ if TYPE_CHECKING:
|
|
|
67
68
|
from ..runtime.session import Runtime
|
|
68
69
|
|
|
69
70
|
#: GPU session shapes, with memory per card, device count and the internal API name.
|
|
70
|
-
#: P100 remains available through the API although the web UI no longer lists it.
|
|
71
71
|
#: CPU is the empty compute, so it carries no accelerator name.
|
|
72
72
|
GPUS = {
|
|
73
|
-
"
|
|
74
|
-
"T4": {"vram_gb": 16, "devices": 2, "accelerator": "NVIDIA_TESLA_T4"},
|
|
73
|
+
"T4": {"vram_gb": 16, "devices": 2, "accelerator": "NVIDIA_TESLA_T4", "model": "T4"},
|
|
75
74
|
}
|
|
76
75
|
TPUS = ("TPU_V3_8",)
|
|
76
|
+
#: Cards Kaggle no longer serves, and what it does instead. Offering one would let a
|
|
77
|
+
#: declaration say P100 and run on something else: spec "Kaggle", the accelerators.
|
|
78
|
+
RETIRED = {
|
|
79
|
+
"P100": (
|
|
80
|
+
"Kaggle retired the Tesla P100 on 2026-09-15 and switches a notebook that asks "
|
|
81
|
+
"for it to two T4s, so the card and the card count would both differ from the "
|
|
82
|
+
"declaration. Use T4, which is two cards of 16 GB"
|
|
83
|
+
),
|
|
84
|
+
}
|
|
77
85
|
|
|
78
86
|
#: The internal Kaggle service surface the web app uses, authenticated by the session cookie.
|
|
79
87
|
KAGGLE_INTERNAL = "https://www.kaggle.com/api/i/"
|
|
@@ -95,6 +103,9 @@ FIRESTORE_DOCUMENT = (
|
|
|
95
103
|
#: runnable. A freshly created empty notebook cancels its own session because it has nothing
|
|
96
104
|
#: to run, so the session is started with one committed cell.
|
|
97
105
|
NOTEBOOK_TITLE = "letify runtime"
|
|
106
|
+
#: What the kernel bridge carries, measured sending 64 MiB on a live account. The
|
|
107
|
+
#: floor compares a link against this rather than refusing one that beats it.
|
|
108
|
+
BRIDGE_MIB_PER_S = 2.0
|
|
98
109
|
NOTEBOOK_BODY = json.dumps(
|
|
99
110
|
{
|
|
100
111
|
"cells": [
|
|
@@ -504,6 +515,9 @@ def live_session_url(
|
|
|
504
515
|
403 on it is the account's own answer, not a stale notebook's.
|
|
505
516
|
"""
|
|
506
517
|
kernel, slug = new_notebook(alias, cookie)
|
|
518
|
+
# Spec "Kaggle", finding the notebooks letify owns: recorded before anything can fail,
|
|
519
|
+
# so a run that dies next still leaves a trail to its notebook.
|
|
520
|
+
record_notebook(alias, slug)
|
|
507
521
|
run = start_run(cookie, kernel, accelerator)
|
|
508
522
|
id_token = firebase_id_token(cookie)
|
|
509
523
|
webtier = webtier_session(cookie, id_token, run)
|
|
@@ -554,6 +568,116 @@ def read_notebook_owner(alias: str) -> str | None:
|
|
|
554
568
|
return owner or None
|
|
555
569
|
|
|
556
570
|
|
|
571
|
+
def notebook_record(alias: str) -> Path:
|
|
572
|
+
"""Where the refs of the notebooks letify created on this account are kept."""
|
|
573
|
+
return account_directory(alias) / "notebooks"
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def recorded_notebooks(alias: str) -> list[str]:
|
|
577
|
+
"""The refs letify believes it created, in the order it created them."""
|
|
578
|
+
try:
|
|
579
|
+
text = notebook_record(alias).read_text(encoding="utf-8")
|
|
580
|
+
except OSError:
|
|
581
|
+
return []
|
|
582
|
+
seen: list[str] = []
|
|
583
|
+
for line in text.splitlines():
|
|
584
|
+
ref = line.strip()
|
|
585
|
+
if ref and ref not in seen:
|
|
586
|
+
seen.append(ref)
|
|
587
|
+
return seen
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def record_notebook(alias: str, slug: str | None) -> None:
|
|
591
|
+
"""Note that letify created this notebook, so a later process can still find it.
|
|
592
|
+
|
|
593
|
+
Spec "Kaggle", finding the notebooks letify owns: Kaggle names the notebook itself, so
|
|
594
|
+
the record is the only thing that tells letify's from the user's own. Never raises; a
|
|
595
|
+
notebook letify cannot record is one it may fail to clean up, not a failed run.
|
|
596
|
+
"""
|
|
597
|
+
username = read_notebook_owner(alias)
|
|
598
|
+
if not slug or username is None:
|
|
599
|
+
return
|
|
600
|
+
try:
|
|
601
|
+
path = notebook_record(alias)
|
|
602
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
603
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
604
|
+
handle.write(f"{username}/{slug}\n")
|
|
605
|
+
except OSError:
|
|
606
|
+
pass
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def forget_notebook(alias: str, slug: str | None) -> None:
|
|
610
|
+
"""Drop a notebook from the record, once it is gone from the account."""
|
|
611
|
+
username = read_notebook_owner(alias)
|
|
612
|
+
if not slug or username is None:
|
|
613
|
+
return
|
|
614
|
+
ref = f"{username}/{slug}"
|
|
615
|
+
kept = [line for line in recorded_notebooks(alias) if line != ref]
|
|
616
|
+
try:
|
|
617
|
+
notebook_record(alias).write_text(
|
|
618
|
+
"".join(f"{line}\n" for line in kept), encoding="utf-8"
|
|
619
|
+
)
|
|
620
|
+
except OSError:
|
|
621
|
+
pass
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def notebooks_on_account(alias: str) -> list[str] | None:
|
|
625
|
+
"""The refs the account has, through the official CLI, or None when it cannot be asked.
|
|
626
|
+
|
|
627
|
+
None and an empty list mean different things: nothing answered, against the account
|
|
628
|
+
having no notebooks. Never raises.
|
|
629
|
+
"""
|
|
630
|
+
if read_api_token(alias) is None:
|
|
631
|
+
return None
|
|
632
|
+
uv = tools.find_uv()
|
|
633
|
+
if uv is None:
|
|
634
|
+
return None
|
|
635
|
+
import csv
|
|
636
|
+
import io
|
|
637
|
+
import subprocess
|
|
638
|
+
|
|
639
|
+
try:
|
|
640
|
+
result = subprocess.run(
|
|
641
|
+
[*tools.kaggle_cli_command(uv), "kernels", "list", "--mine", "--csv"],
|
|
642
|
+
env=tools.kaggle_cli_environment(alias),
|
|
643
|
+
capture_output=True,
|
|
644
|
+
text=True,
|
|
645
|
+
timeout=REST_TIMEOUT,
|
|
646
|
+
)
|
|
647
|
+
except (OSError, subprocess.SubprocessError):
|
|
648
|
+
return None
|
|
649
|
+
if result.returncode != 0:
|
|
650
|
+
return None
|
|
651
|
+
try:
|
|
652
|
+
rows = list(csv.DictReader(io.StringIO(result.stdout or "")))
|
|
653
|
+
except csv.Error:
|
|
654
|
+
return None
|
|
655
|
+
return [(row.get("ref") or "").strip() for row in rows if (row.get("ref") or "").strip()]
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def list_notebooks_via_cli(alias: str) -> list[str]:
|
|
659
|
+
"""The recorded notebooks this account still has. Spec "Kaggle", finding them.
|
|
660
|
+
|
|
661
|
+
A ref the record names and the account no longer has was deleted elsewhere, so it is
|
|
662
|
+
dropped from the record rather than reported.
|
|
663
|
+
"""
|
|
664
|
+
recorded = recorded_notebooks(alias)
|
|
665
|
+
if not recorded:
|
|
666
|
+
return []
|
|
667
|
+
present = notebooks_on_account(alias)
|
|
668
|
+
if present is None:
|
|
669
|
+
return []
|
|
670
|
+
live = [ref for ref in recorded if ref in set(present)]
|
|
671
|
+
if live != recorded:
|
|
672
|
+
try:
|
|
673
|
+
notebook_record(alias).write_text(
|
|
674
|
+
"".join(f"{ref}\n" for ref in live), encoding="utf-8"
|
|
675
|
+
)
|
|
676
|
+
except OSError:
|
|
677
|
+
pass
|
|
678
|
+
return live
|
|
679
|
+
|
|
680
|
+
|
|
557
681
|
def delete_notebook_via_cli(alias: str, slug: str | None) -> bool:
|
|
558
682
|
"""Delete the ephemeral notebook through the official CLI, the only way it is deleted.
|
|
559
683
|
|
|
@@ -596,6 +720,7 @@ def delete_notebook_best_effort(alias: str, kernel_id: int, slug: str | None) ->
|
|
|
596
720
|
slug or id, never by its cookie, so the user can delete it by hand on kaggle.com.
|
|
597
721
|
"""
|
|
598
722
|
if delete_notebook_via_cli(alias, slug):
|
|
723
|
+
forget_notebook(alias, slug)
|
|
599
724
|
return
|
|
600
725
|
import sys
|
|
601
726
|
|
|
@@ -990,7 +1115,9 @@ class Kaggle(Provider):
|
|
|
990
1115
|
usage_unit = "GPU hours"
|
|
991
1116
|
usage_source = "the weekly accelerator quota the official Kaggle CLI's quota command reads"
|
|
992
1117
|
|
|
993
|
-
|
|
1118
|
+
# Spec "Kaggle runtimes": /kaggle/working is the notebook's output directory on its
|
|
1119
|
+
# own 19.5 GB device, while /kaggle is the overlay with about 1 TB free.
|
|
1120
|
+
default_workspace = "/kaggle/letify"
|
|
994
1121
|
|
|
995
1122
|
def account_note(self) -> str | None:
|
|
996
1123
|
"""How the account's cookie is doing, for `letify providers`, with no network call.
|
|
@@ -1025,6 +1152,24 @@ class Kaggle(Provider):
|
|
|
1025
1152
|
table.update({name: Instance(self, tpu=name) for name in TPUS})
|
|
1026
1153
|
return table
|
|
1027
1154
|
|
|
1155
|
+
discovers_sessions = True
|
|
1156
|
+
|
|
1157
|
+
def sessions(self) -> list[str]:
|
|
1158
|
+
"""The notebooks letify owns on this account, which is where an orphan shows up."""
|
|
1159
|
+
return list_notebooks_via_cli(self.alias)
|
|
1160
|
+
|
|
1161
|
+
def expected_cards(self, instance: Any) -> tuple[str, int] | None:
|
|
1162
|
+
"""What a Kaggle session must have, because Kaggle may answer with another card."""
|
|
1163
|
+
gpu = getattr(instance, "gpu", None)
|
|
1164
|
+
spec = GPUS.get(gpu) if gpu else None
|
|
1165
|
+
if spec is None:
|
|
1166
|
+
return None
|
|
1167
|
+
return str(spec["model"]), int(spec["devices"])
|
|
1168
|
+
|
|
1169
|
+
def retired_reason(self, name: str) -> str | None:
|
|
1170
|
+
"""Why Kaggle no longer serves a card it once did. Spec "Kaggle", the accelerators."""
|
|
1171
|
+
return RETIRED.get(name.upper())
|
|
1172
|
+
|
|
1028
1173
|
def store_backend(self) -> str:
|
|
1029
1174
|
return "filesystem"
|
|
1030
1175
|
|
|
@@ -1179,6 +1324,7 @@ class Kaggle(Provider):
|
|
|
1179
1324
|
return LinkFloor(
|
|
1180
1325
|
min_bps=float(min_mib) * MIB if isinstance(min_mib, (int, float)) else default.min_bps,
|
|
1181
1326
|
max_rtt_ms=float(max_rtt) if isinstance(max_rtt, (int, float)) else default.max_rtt_ms,
|
|
1327
|
+
fallback_bps=self.fallback_mib_per_s * MIB,
|
|
1182
1328
|
)
|
|
1183
1329
|
|
|
1184
1330
|
@property
|
|
@@ -1199,7 +1345,15 @@ class Kaggle(Provider):
|
|
|
1199
1345
|
|
|
1200
1346
|
try:
|
|
1201
1347
|
link = self.link_over(bridge, name=name)
|
|
1202
|
-
except Exception:
|
|
1348
|
+
except Exception as exc:
|
|
1349
|
+
# Never silent: a session that quietly changed channel is one nobody can
|
|
1350
|
+
# account for. Spec "A floor rejection never picks something slower".
|
|
1351
|
+
from ..transport.announce import printer
|
|
1352
|
+
|
|
1353
|
+
printer(bool(self.__dict__.get("announce", True)))(
|
|
1354
|
+
f"{self.alias}: the kernel bridge is carrying {name} at about "
|
|
1355
|
+
f"{self.fallback_mib_per_s:.1f} MiB/s, because no link was chosen: {exc}"
|
|
1356
|
+
)
|
|
1203
1357
|
return bridge
|
|
1204
1358
|
self._links[name] = link
|
|
1205
1359
|
return PersistentChannel(
|
|
@@ -1207,6 +1361,17 @@ class Kaggle(Provider):
|
|
|
1207
1361
|
name=name,
|
|
1208
1362
|
)
|
|
1209
1363
|
|
|
1364
|
+
@property
|
|
1365
|
+
def fallback_mib_per_s(self) -> float:
|
|
1366
|
+
"""The kernel bridge, measured at 2.0 MiB/s sending 64 MiB on a live account.
|
|
1367
|
+
|
|
1368
|
+
Spec "A floor rejection never picks something slower": without this a punched link
|
|
1369
|
+
at down 8.1 MiB/s was refused for being under the 10 MiB/s floor and 26 GB went
|
|
1370
|
+
over the 2 MiB/s bridge instead.
|
|
1371
|
+
"""
|
|
1372
|
+
value = self.config.option("fallback_mib_per_s")
|
|
1373
|
+
return max(0.0, float(value)) if isinstance(value, (int, float)) else BRIDGE_MIB_PER_S
|
|
1374
|
+
|
|
1210
1375
|
@property
|
|
1211
1376
|
def transfer_connection_cost_s(self) -> float:
|
|
1212
1377
|
"""A punch, which is what one more stream costs here. Measured at about 10 s."""
|
|
@@ -130,6 +130,24 @@ def project_dir(workspace_root: str, env: Env) -> str:
|
|
|
130
130
|
return f"{workspace_root.rstrip('/')}/project/{env.key}"
|
|
131
131
|
|
|
132
132
|
|
|
133
|
+
#: Reads the cards a runtime has, without nvidia-smi, which a Kaggle image lacks on PATH.
|
|
134
|
+
#: Spec "Kaggle", the accelerators.
|
|
135
|
+
CARD_SOURCE = (
|
|
136
|
+
"import glob\n"
|
|
137
|
+
"__letify_cards = []\n"
|
|
138
|
+
"for _letify_path in sorted(glob.glob('/proc/driver/nvidia/gpus/*/information')):\n"
|
|
139
|
+
" try:\n"
|
|
140
|
+
" with open(_letify_path) as _letify_handle:\n"
|
|
141
|
+
" for _letify_line in _letify_handle:\n"
|
|
142
|
+
" if _letify_line.startswith('Model:'):\n"
|
|
143
|
+
" __letify_cards.append(_letify_line.split(':', 1)[1].strip())\n"
|
|
144
|
+
" break\n"
|
|
145
|
+
" except OSError:\n"
|
|
146
|
+
" pass\n"
|
|
147
|
+
"__letify_value__ = __letify_cards\n"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
133
151
|
def packages_in(files: dict[str, bytes]) -> int:
|
|
134
152
|
"""How many packages the lock names, for the line the environment build prints.
|
|
135
153
|
|
|
@@ -158,6 +176,15 @@ def project_files(env: Env) -> dict[str, bytes]:
|
|
|
158
176
|
f"pyproject.toml and uv.lock, and {directory.resolve()} has no {name}. "
|
|
159
177
|
f"Run 'uv lock' in the project first"
|
|
160
178
|
)
|
|
179
|
+
lock = files.get("uv.lock") or b""
|
|
180
|
+
if b'name = "letify"' not in lock and b'name = "cloudpickle"' not in lock:
|
|
181
|
+
raise ConfigError(
|
|
182
|
+
f"the worker unpickles a declared body with cloudpickle, and a body that uses "
|
|
183
|
+
f"letify's own names imports letify on the runtime, so "
|
|
184
|
+
f"{(directory / 'uv.lock').resolve()} has to name letify or at least cloudpickle. "
|
|
185
|
+
f"It names neither, and the session would fail there with ModuleNotFoundError. "
|
|
186
|
+
f"Add letify to the project and run 'uv lock' again"
|
|
187
|
+
)
|
|
161
188
|
local = local_python()
|
|
162
189
|
pinned = files.get(".python-version")
|
|
163
190
|
if pinned is not None:
|
|
@@ -130,6 +130,7 @@ class Runtime:
|
|
|
130
130
|
self.worker_pid = int(self.stat()["pid"])
|
|
131
131
|
self.provider.add_worker_pid(self.worker_pid)
|
|
132
132
|
self.restrict_devices()
|
|
133
|
+
self.check_devices()
|
|
133
134
|
if self.provider.needs_lease:
|
|
134
135
|
self.lease = Lease(self)
|
|
135
136
|
self.lease.arm()
|
|
@@ -691,6 +692,43 @@ class Runtime:
|
|
|
691
692
|
self.python = where["python"]
|
|
692
693
|
self.channel.switch_interpreter(where["python"])
|
|
693
694
|
|
|
695
|
+
def check_devices(self) -> None:
|
|
696
|
+
"""Refuse a session whose cards are not the ones the instance asked for.
|
|
697
|
+
|
|
698
|
+
Spec "Kaggle", the accelerators: a provider may answer a request with a different
|
|
699
|
+
card. Run before the environment is built, so a wrong card costs seconds rather
|
|
700
|
+
than the minutes a sync takes.
|
|
701
|
+
"""
|
|
702
|
+
from ..errors import InsufficientDevices
|
|
703
|
+
from . import bootstrap
|
|
704
|
+
|
|
705
|
+
instance = getattr(self, "instance", None)
|
|
706
|
+
expected = self.provider.expected_cards(instance)
|
|
707
|
+
if expected is None:
|
|
708
|
+
return
|
|
709
|
+
fragment, count = expected
|
|
710
|
+
try:
|
|
711
|
+
cards = self.eval(bootstrap.CARD_SOURCE, timeout=120)
|
|
712
|
+
except Exception:
|
|
713
|
+
# Reading /proc is not worth failing a session over when it cannot be read.
|
|
714
|
+
return
|
|
715
|
+
cards = [str(card) for card in cards or ()]
|
|
716
|
+
matched = [card for card in cards if fragment.casefold() in card.casefold()]
|
|
717
|
+
if len(matched) == count and len(cards) == count:
|
|
718
|
+
return
|
|
719
|
+
asked = getattr(instance, "accelerator", None) or fragment
|
|
720
|
+
arrived = ", ".join(cards) if cards else "no GPU at all"
|
|
721
|
+
message = (
|
|
722
|
+
f"{self.name}: {self.provider.alias} was asked for {count} x {asked} and the "
|
|
723
|
+
f"session has {arrived}. A declaration says where a function runs, so the "
|
|
724
|
+
f"session is not used. Set any_accelerator = true on the account to accept "
|
|
725
|
+
f"whatever card it is given"
|
|
726
|
+
)
|
|
727
|
+
if self.provider.any_accelerator:
|
|
728
|
+
self._say(f"card mismatch accepted: asked {count} x {asked}, got {arrived}")
|
|
729
|
+
return
|
|
730
|
+
raise InsufficientDevices(message)
|
|
731
|
+
|
|
694
732
|
def check_interpreter(self) -> None:
|
|
695
733
|
"""Refuse a worker whose Python major.minor differs from this process."""
|
|
696
734
|
from . import bootstrap
|
|
@@ -61,11 +61,39 @@ class LinkFloor:
|
|
|
61
61
|
|
|
62
62
|
min_bps: float
|
|
63
63
|
max_rtt_ms: float
|
|
64
|
+
#: What the provider's own fallback carries, when it is known. A link the floor
|
|
65
|
+
#: rejected is kept when it beats this. Spec "A floor rejection never picks something
|
|
66
|
+
#: slower".
|
|
67
|
+
fallback_bps: float = 0.0
|
|
64
68
|
|
|
65
69
|
@classmethod
|
|
66
70
|
def default(cls) -> LinkFloor:
|
|
67
71
|
return cls(min_bps=DEFAULT_MIN_MIB_PER_S * MIB, max_rtt_ms=DEFAULT_MAX_RTT_MS)
|
|
68
72
|
|
|
73
|
+
def kept_below(self, result: ProbeResult, floored: list[str]) -> str | None:
|
|
74
|
+
"""Why a link the floor rejected is used anyway, or None when it is not.
|
|
75
|
+
|
|
76
|
+
Spec "A floor rejection never picks something slower".
|
|
77
|
+
"""
|
|
78
|
+
if not floored or not self.beats_fallback(result):
|
|
79
|
+
return None
|
|
80
|
+
return (
|
|
81
|
+
f"kept below the floor, because the fallback carries "
|
|
82
|
+
f"{self.fallback_bps / MIB:.1f} MiB/s"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def beats_fallback(self, result: ProbeResult) -> bool:
|
|
86
|
+
"""Whether this probe is faster than the path that would be used instead.
|
|
87
|
+
|
|
88
|
+
Spec "A floor rejection never picks something slower": the floor refuses a slow
|
|
89
|
+
link so a slow link is not used, which is only right when what happens instead is
|
|
90
|
+
better. A provider with no measurable fallback has nothing to compare against, so
|
|
91
|
+
the answer is False and the rejection stands.
|
|
92
|
+
"""
|
|
93
|
+
if not self.fallback_bps:
|
|
94
|
+
return False
|
|
95
|
+
return result.upload_bps > self.fallback_bps and result.download_bps > self.fallback_bps
|
|
96
|
+
|
|
69
97
|
def violations(self, result: ProbeResult) -> list[str]:
|
|
70
98
|
"""Each direction, or the round trip, that the probe failed to clear."""
|
|
71
99
|
found = _below(
|
|
@@ -312,6 +340,10 @@ class Pipeline:
|
|
|
312
340
|
return link
|
|
313
341
|
if measured is not None:
|
|
314
342
|
floored = self.floor.violations(measured)
|
|
343
|
+
kept = self.floor.kept_below(measured, floored)
|
|
344
|
+
if kept:
|
|
345
|
+
self._say(f"{link.strategy} {kept}")
|
|
346
|
+
floored = []
|
|
315
347
|
if floored:
|
|
316
348
|
self._say(f"rejected {link.strategy}: below the floor: {', '.join(floored)}")
|
|
317
349
|
_close(link)
|
|
@@ -389,6 +421,10 @@ class Pipeline:
|
|
|
389
421
|
_close(link)
|
|
390
422
|
return None
|
|
391
423
|
floored = self.floor.violations(measured)
|
|
424
|
+
kept = self.floor.kept_below(measured, floored)
|
|
425
|
+
if kept:
|
|
426
|
+
self._say(f"cached {name} {kept}")
|
|
427
|
+
floored = []
|
|
392
428
|
if floored:
|
|
393
429
|
self._say(f"cached {name} rejected: below the floor: {', '.join(floored)}")
|
|
394
430
|
reasons.append(f"{name}: below the floor: {', '.join(floored)}")
|
|
@@ -573,6 +609,10 @@ class Pipeline:
|
|
|
573
609
|
else:
|
|
574
610
|
if measured is not None:
|
|
575
611
|
floored = self.floor.violations(measured)
|
|
612
|
+
kept = self.floor.kept_below(measured, floored)
|
|
613
|
+
if kept:
|
|
614
|
+
self._say(f"{link.strategy} {kept}")
|
|
615
|
+
floored = []
|
|
576
616
|
if floored:
|
|
577
617
|
below = f"{link.strategy}: below the floor: {', '.join(floored)}"
|
|
578
618
|
self._say(f"rejected {below}")
|
|
@@ -598,6 +638,10 @@ class Pipeline:
|
|
|
598
638
|
above_floor: list[tuple[Any, ProbeResult]] = []
|
|
599
639
|
for link, measured in probed:
|
|
600
640
|
floored = self.floor.violations(measured)
|
|
641
|
+
kept = self.floor.kept_below(measured, floored)
|
|
642
|
+
if kept:
|
|
643
|
+
self._say(f"{link.strategy} {kept}")
|
|
644
|
+
floored = []
|
|
601
645
|
if floored:
|
|
602
646
|
self._say(f"rejected {link.strategy}: below the floor: {', '.join(floored)}")
|
|
603
647
|
below = f"{link.strategy}: below the floor: {', '.join(floored)}"
|
|
@@ -138,7 +138,7 @@ dependencies = [
|
|
|
138
138
|
|
|
139
139
|
[[package]]
|
|
140
140
|
name = "letify-agent"
|
|
141
|
-
version = "2.
|
|
141
|
+
version = "2.4.0"
|
|
142
142
|
dependencies = [
|
|
143
143
|
"letify-wire",
|
|
144
144
|
"libloading",
|
|
@@ -146,7 +146,7 @@ dependencies = [
|
|
|
146
146
|
|
|
147
147
|
[[package]]
|
|
148
148
|
name = "letify-driver"
|
|
149
|
-
version = "2.
|
|
149
|
+
version = "2.4.0"
|
|
150
150
|
dependencies = [
|
|
151
151
|
"letify-wire",
|
|
152
152
|
]
|
|
@@ -164,7 +164,7 @@ dependencies = [
|
|
|
164
164
|
|
|
165
165
|
[[package]]
|
|
166
166
|
name = "letify-wire"
|
|
167
|
-
version = "2.
|
|
167
|
+
version = "2.4.0"
|
|
168
168
|
|
|
169
169
|
[[package]]
|
|
170
170
|
name = "libc"
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letify-status",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "letify-status",
|
|
9
|
-
"version": "2.
|
|
9
|
+
"version": "2.4.0",
|
|
10
10
|
"license": "SEE LICENSE IN LICENSE",
|
|
11
11
|
"devDependencies": {
|
|
12
12
|
"@types/node": "^18.19.0",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "letify-status",
|
|
3
3
|
"displayName": "letify Status",
|
|
4
4
|
"description": "letify quota and GPU activity in the status bar, read from the letify CLI.",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.4.0",
|
|
6
6
|
"publisher": "darkpyonix",
|
|
7
7
|
"license": "SEE LICENSE IN LICENSE",
|
|
8
8
|
"repository": {
|
|
@@ -1693,6 +1693,15 @@ class FakeKaggleCLI:
|
|
|
1693
1693
|
self.calls: list[list[str]] = []
|
|
1694
1694
|
self.environments: list[dict[str, str]] = []
|
|
1695
1695
|
self.fail: set[str] = set()
|
|
1696
|
+
#: Standard output a ``kernels list --csv`` call answers with. Kaggle names a
|
|
1697
|
+
#: notebook itself, so the titles are its own and the record is what tells
|
|
1698
|
+
#: letify's notebooks from the user's.
|
|
1699
|
+
self.kernels_csv = (
|
|
1700
|
+
"ref,title,author,lastRunTime,totalVotes\n"
|
|
1701
|
+
"irack000/letify-runtime,notebook8843ff70eb,irack000,2026-10-10 05:00:00,0\n"
|
|
1702
|
+
"irack000/my-analysis,My analysis,irack000,2026-10-09 11:00:00,3\n"
|
|
1703
|
+
"irack000/letify-runtime-2,notebook77c1a2,irack000,2026-10-10 06:00:00,0\n"
|
|
1704
|
+
)
|
|
1696
1705
|
#: Standard output a ``quota`` call answers with, as JSON text.
|
|
1697
1706
|
self.quota_json = json.dumps([
|
|
1698
1707
|
{"resource": "GPU", "used": "0.00h", "remaining": "60.00h", "total": "60.00h",
|
|
@@ -1712,6 +1721,8 @@ class FakeKaggleCLI:
|
|
|
1712
1721
|
return FakeCompleted(returncode=1, stdout="", stderr="refused")
|
|
1713
1722
|
if "quota" in command:
|
|
1714
1723
|
return FakeCompleted(returncode=0, stdout=self.quota_json, stderr="")
|
|
1724
|
+
if "list" in command:
|
|
1725
|
+
return FakeCompleted(returncode=0, stdout=self.kernels_csv, stderr="")
|
|
1715
1726
|
return FakeCompleted(returncode=0, stdout="{}", stderr="")
|
|
1716
1727
|
|
|
1717
1728
|
|
|
@@ -841,7 +841,11 @@ def test_a_refused_spot_launch_ends_nothing(account, fake_eci, capsys) -> None:
|
|
|
841
841
|
}
|
|
842
842
|
)
|
|
843
843
|
Path("pyproject.toml").write_text("[project]\nname = 'study'\n", encoding="utf-8")
|
|
844
|
-
|
|
844
|
+
# Names cloudpickle because a lock naming neither it nor letify is refused before
|
|
845
|
+
# the launch, and this test is about the launch being refused.
|
|
846
|
+
Path("uv.lock").write_text(
|
|
847
|
+
'version = 1\n\n[[package]]\nname = "cloudpickle"\n', encoding="utf-8"
|
|
848
|
+
)
|
|
845
849
|
provider = account()
|
|
846
850
|
instance = Instance(provider, gpu="A100").priced("spot")._placed("remote")
|
|
847
851
|
with pytest.raises(letify.ProviderUnavailable):
|