letify 2.2.0__tar.gz → 2.3.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.3.0}/PKG-INFO +1 -1
- {letify-2.2.0 → letify-2.3.0}/letify/__init__.py +1 -1
- {letify-2.2.0 → letify-2.3.0}/letify/launcher.py +1 -1
- {letify-2.2.0 → letify-2.3.0}/letify/providers/base.py +38 -0
- {letify-2.2.0 → letify-2.3.0}/letify/providers/colab.py +2 -0
- {letify-2.2.0 → letify-2.3.0}/letify/providers/kaggle.py +143 -3
- {letify-2.2.0 → letify-2.3.0}/letify/runtime/bootstrap.py +27 -0
- {letify-2.2.0 → letify-2.3.0}/letify/runtime/session.py +38 -0
- {letify-2.2.0 → letify-2.3.0}/letify-core/Cargo.lock +3 -3
- {letify-2.2.0 → letify-2.3.0}/letify-core/Cargo.toml +1 -1
- {letify-2.2.0 → letify-2.3.0}/letify-ext/package-lock.json +2 -2
- {letify-2.2.0 → letify-2.3.0}/letify-ext/package.json +1 -1
- {letify-2.2.0 → letify-2.3.0}/pyproject.toml +1 -1
- {letify-2.2.0 → letify-2.3.0}/tests/conftest.py +11 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_elice.py +5 -1
- {letify-2.2.0 → letify-2.3.0}/tests/test_kaggle.py +112 -4
- {letify-2.2.0 → letify-2.3.0}/tests/test_kaggle_link.py +18 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_runtime.py +32 -1
- {letify-2.2.0 → letify-2.3.0}/.github/workflows/ci.yml +0 -0
- {letify-2.2.0 → letify-2.3.0}/.github/workflows/docs.yml +0 -0
- {letify-2.2.0 → letify-2.3.0}/.github/workflows/publish.yml +0 -0
- {letify-2.2.0 → letify-2.3.0}/.gitignore +0 -0
- {letify-2.2.0 → letify-2.3.0}/.researchtree +0 -0
- {letify-2.2.0 → letify-2.3.0}/LICENSE +0 -0
- {letify-2.2.0 → letify-2.3.0}/README.md +0 -0
- {letify-2.2.0 → letify-2.3.0}/docs/guide/01-getting-started.md +0 -0
- {letify-2.2.0 → letify-2.3.0}/docs/guide/02-providers.md +0 -0
- {letify-2.2.0 → letify-2.3.0}/docs/guide/03-execution-modes.md +0 -0
- {letify-2.2.0 → letify-2.3.0}/docs/guide/04-environments-and-data.md +0 -0
- {letify-2.2.0 → letify-2.3.0}/docs/guide/05-concurrency.md +0 -0
- {letify-2.2.0 → letify-2.3.0}/docs/guide/06-cost.md +0 -0
- {letify-2.2.0 → letify-2.3.0}/docs/guide/07-troubleshooting.md +0 -0
- {letify-2.2.0 → letify-2.3.0}/docs/guide/08-releasing.md +0 -0
- {letify-2.2.0 → letify-2.3.0}/docs/guide/README.md +0 -0
- {letify-2.2.0 → letify-2.3.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.3.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.3.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.3.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.3.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.3.0}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-2.2.0 → letify-2.3.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.3.0}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-2.2.0 → letify-2.3.0}/docs/guide/ko/README.md +0 -0
- {letify-2.2.0 → letify-2.3.0}/docs/locales/README_ko.md +0 -0
- {letify-2.2.0 → letify-2.3.0}/docs/mkdocs.yml +0 -0
- {letify-2.2.0 → letify-2.3.0}/examples/README.md +0 -0
- {letify-2.2.0 → letify-2.3.0}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-2.2.0 → letify-2.3.0}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/examples/nvfp4-lora/README.md +0 -0
- {letify-2.2.0 → letify-2.3.0}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-2.2.0 → letify-2.3.0}/examples/nvfp4-lora/common.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/cli.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/config/__init__.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/config/inventory.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/config/login.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/config/schema.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/config/secrets.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/config/writer.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/declare/__init__.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/declare/cache.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/declare/env.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/declare/function.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/declare/instance.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/errors.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/install.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/paths.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/protocol/__init__.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/protocol/codec.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/protocol/driver.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/protocol/handle.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/protocol/wire.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/protocol/worker.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/providers/__init__.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/providers/colab_files.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/providers/colab_keepalive.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/providers/elice.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/providers/kaggle_adapter.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/providers/local.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/providers/modal.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/providers/modal_adapter.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/providers/naming.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/providers/shell.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/providers/tunnel.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/providers/usage.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/remoting/__init__.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/remoting/capability.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/remoting/device/__init__.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/remoting/device/client.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/remoting/device/cuda.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/remoting/device/executor.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/remoting/device/frames.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/remoting/device/guard.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/remoting/device/tensor.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/remoting/device/trace.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/remoting/device/value.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/remoting/loader.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/remoting/probe.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/render.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/runtime/__init__.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/runtime/channel.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/runtime/lease.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/runtime/pool.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/runtime/telemetry.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/store/__init__.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/store/backends/__init__.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/store/backends/filesystem.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/store/backends/google_auth.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/store/backends/layout.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/store/backends/objects.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/store/cas.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/store/pathdata.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/store/sendorder.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/store/volume.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/stubs.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/tools.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/transport/__init__.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/transport/agent.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/transport/announce.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/transport/link.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/transport/nat.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/transport/pipeline.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/transport/portmap.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/transport/probe.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/transport/quic.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/transport/rendezvous.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/transport/setup.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/transport/sshopts.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify/transport/strategies.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-core/.gitignore +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-core/build.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-core/letify-quic/Cargo.toml +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-core/letify-quic/src/main.rs +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-core/rust-toolchain.toml +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-ext/.vscodeignore +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-ext/LICENSE +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-ext/README.md +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-ext/media/icon.svg +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-ext/src/cli.ts +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-ext/src/extension.ts +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-ext/src/history.ts +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-ext/src/model.ts +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-ext/src/poll.ts +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-ext/src/render.ts +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-ext/test/cli.test.ts +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-ext/test/manifest.test.ts +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-ext/test/model.test.ts +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-ext/test/regressions.test.ts +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify-ext/tsconfig.json +0 -0
- {letify-2.2.0 → letify-2.3.0}/letify_providers.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/scripts/sync-main.sh +0 -0
- {letify-2.2.0 → letify-2.3.0}/scripts/version.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/fake_kaggle_adapter.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/fake_modal_adapter.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_cli.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_cli_output.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_colab_keepalive.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_config.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_core.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_data.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_data_streaming.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_declare.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_device.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_editor_sessions.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_frames.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_install.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_kaggle_cookie.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_link_log.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_notebook.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_parallel_placement.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_paths.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_portmap.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_protocol.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_providers.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_quic.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_remoting.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_render.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_ship.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_store.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_strategies.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_stubs.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_transport.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_tunnel_setup.py +0 -0
- {letify-2.2.0 → letify-2.3.0}/tests/test_version.py +0 -0
|
@@ -354,10 +354,48 @@ 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 any_accelerator(self) -> bool:
|
|
388
|
+
"""Whether a card other than the one asked for is accepted, from ``any_accelerator``."""
|
|
389
|
+
return self.config.option("any_accelerator") is True
|
|
390
|
+
|
|
391
|
+
def retired_reason(self, name: str) -> str | None:
|
|
392
|
+
"""Why an accelerator this provider once offered is gone, or None.
|
|
393
|
+
|
|
394
|
+
A name the provider's service answers with a different card belongs here rather
|
|
395
|
+
than in the inventory, because a declaration states where a function runs.
|
|
396
|
+
"""
|
|
397
|
+
return None
|
|
398
|
+
|
|
361
399
|
def device(self, name: str) -> Instance:
|
|
362
400
|
"""Look up an accelerator by name."""
|
|
363
401
|
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/"
|
|
@@ -504,6 +512,9 @@ def live_session_url(
|
|
|
504
512
|
403 on it is the account's own answer, not a stale notebook's.
|
|
505
513
|
"""
|
|
506
514
|
kernel, slug = new_notebook(alias, cookie)
|
|
515
|
+
# Spec "Kaggle", finding the notebooks letify owns: recorded before anything can fail,
|
|
516
|
+
# so a run that dies next still leaves a trail to its notebook.
|
|
517
|
+
record_notebook(alias, slug)
|
|
507
518
|
run = start_run(cookie, kernel, accelerator)
|
|
508
519
|
id_token = firebase_id_token(cookie)
|
|
509
520
|
webtier = webtier_session(cookie, id_token, run)
|
|
@@ -554,6 +565,116 @@ def read_notebook_owner(alias: str) -> str | None:
|
|
|
554
565
|
return owner or None
|
|
555
566
|
|
|
556
567
|
|
|
568
|
+
def notebook_record(alias: str) -> Path:
|
|
569
|
+
"""Where the refs of the notebooks letify created on this account are kept."""
|
|
570
|
+
return account_directory(alias) / "notebooks"
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def recorded_notebooks(alias: str) -> list[str]:
|
|
574
|
+
"""The refs letify believes it created, in the order it created them."""
|
|
575
|
+
try:
|
|
576
|
+
text = notebook_record(alias).read_text(encoding="utf-8")
|
|
577
|
+
except OSError:
|
|
578
|
+
return []
|
|
579
|
+
seen: list[str] = []
|
|
580
|
+
for line in text.splitlines():
|
|
581
|
+
ref = line.strip()
|
|
582
|
+
if ref and ref not in seen:
|
|
583
|
+
seen.append(ref)
|
|
584
|
+
return seen
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def record_notebook(alias: str, slug: str | None) -> None:
|
|
588
|
+
"""Note that letify created this notebook, so a later process can still find it.
|
|
589
|
+
|
|
590
|
+
Spec "Kaggle", finding the notebooks letify owns: Kaggle names the notebook itself, so
|
|
591
|
+
the record is the only thing that tells letify's from the user's own. Never raises; a
|
|
592
|
+
notebook letify cannot record is one it may fail to clean up, not a failed run.
|
|
593
|
+
"""
|
|
594
|
+
username = read_notebook_owner(alias)
|
|
595
|
+
if not slug or username is None:
|
|
596
|
+
return
|
|
597
|
+
try:
|
|
598
|
+
path = notebook_record(alias)
|
|
599
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
600
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
601
|
+
handle.write(f"{username}/{slug}\n")
|
|
602
|
+
except OSError:
|
|
603
|
+
pass
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def forget_notebook(alias: str, slug: str | None) -> None:
|
|
607
|
+
"""Drop a notebook from the record, once it is gone from the account."""
|
|
608
|
+
username = read_notebook_owner(alias)
|
|
609
|
+
if not slug or username is None:
|
|
610
|
+
return
|
|
611
|
+
ref = f"{username}/{slug}"
|
|
612
|
+
kept = [line for line in recorded_notebooks(alias) if line != ref]
|
|
613
|
+
try:
|
|
614
|
+
notebook_record(alias).write_text(
|
|
615
|
+
"".join(f"{line}\n" for line in kept), encoding="utf-8"
|
|
616
|
+
)
|
|
617
|
+
except OSError:
|
|
618
|
+
pass
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def notebooks_on_account(alias: str) -> list[str] | None:
|
|
622
|
+
"""The refs the account has, through the official CLI, or None when it cannot be asked.
|
|
623
|
+
|
|
624
|
+
None and an empty list mean different things: nothing answered, against the account
|
|
625
|
+
having no notebooks. Never raises.
|
|
626
|
+
"""
|
|
627
|
+
if read_api_token(alias) is None:
|
|
628
|
+
return None
|
|
629
|
+
uv = tools.find_uv()
|
|
630
|
+
if uv is None:
|
|
631
|
+
return None
|
|
632
|
+
import csv
|
|
633
|
+
import io
|
|
634
|
+
import subprocess
|
|
635
|
+
|
|
636
|
+
try:
|
|
637
|
+
result = subprocess.run(
|
|
638
|
+
[*tools.kaggle_cli_command(uv), "kernels", "list", "--mine", "--csv"],
|
|
639
|
+
env=tools.kaggle_cli_environment(alias),
|
|
640
|
+
capture_output=True,
|
|
641
|
+
text=True,
|
|
642
|
+
timeout=REST_TIMEOUT,
|
|
643
|
+
)
|
|
644
|
+
except (OSError, subprocess.SubprocessError):
|
|
645
|
+
return None
|
|
646
|
+
if result.returncode != 0:
|
|
647
|
+
return None
|
|
648
|
+
try:
|
|
649
|
+
rows = list(csv.DictReader(io.StringIO(result.stdout or "")))
|
|
650
|
+
except csv.Error:
|
|
651
|
+
return None
|
|
652
|
+
return [(row.get("ref") or "").strip() for row in rows if (row.get("ref") or "").strip()]
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def list_notebooks_via_cli(alias: str) -> list[str]:
|
|
656
|
+
"""The recorded notebooks this account still has. Spec "Kaggle", finding them.
|
|
657
|
+
|
|
658
|
+
A ref the record names and the account no longer has was deleted elsewhere, so it is
|
|
659
|
+
dropped from the record rather than reported.
|
|
660
|
+
"""
|
|
661
|
+
recorded = recorded_notebooks(alias)
|
|
662
|
+
if not recorded:
|
|
663
|
+
return []
|
|
664
|
+
present = notebooks_on_account(alias)
|
|
665
|
+
if present is None:
|
|
666
|
+
return []
|
|
667
|
+
live = [ref for ref in recorded if ref in set(present)]
|
|
668
|
+
if live != recorded:
|
|
669
|
+
try:
|
|
670
|
+
notebook_record(alias).write_text(
|
|
671
|
+
"".join(f"{ref}\n" for ref in live), encoding="utf-8"
|
|
672
|
+
)
|
|
673
|
+
except OSError:
|
|
674
|
+
pass
|
|
675
|
+
return live
|
|
676
|
+
|
|
677
|
+
|
|
557
678
|
def delete_notebook_via_cli(alias: str, slug: str | None) -> bool:
|
|
558
679
|
"""Delete the ephemeral notebook through the official CLI, the only way it is deleted.
|
|
559
680
|
|
|
@@ -596,6 +717,7 @@ def delete_notebook_best_effort(alias: str, kernel_id: int, slug: str | None) ->
|
|
|
596
717
|
slug or id, never by its cookie, so the user can delete it by hand on kaggle.com.
|
|
597
718
|
"""
|
|
598
719
|
if delete_notebook_via_cli(alias, slug):
|
|
720
|
+
forget_notebook(alias, slug)
|
|
599
721
|
return
|
|
600
722
|
import sys
|
|
601
723
|
|
|
@@ -1025,6 +1147,24 @@ class Kaggle(Provider):
|
|
|
1025
1147
|
table.update({name: Instance(self, tpu=name) for name in TPUS})
|
|
1026
1148
|
return table
|
|
1027
1149
|
|
|
1150
|
+
discovers_sessions = True
|
|
1151
|
+
|
|
1152
|
+
def sessions(self) -> list[str]:
|
|
1153
|
+
"""The notebooks letify owns on this account, which is where an orphan shows up."""
|
|
1154
|
+
return list_notebooks_via_cli(self.alias)
|
|
1155
|
+
|
|
1156
|
+
def expected_cards(self, instance: Any) -> tuple[str, int] | None:
|
|
1157
|
+
"""What a Kaggle session must have, because Kaggle may answer with another card."""
|
|
1158
|
+
gpu = getattr(instance, "gpu", None)
|
|
1159
|
+
spec = GPUS.get(gpu) if gpu else None
|
|
1160
|
+
if spec is None:
|
|
1161
|
+
return None
|
|
1162
|
+
return str(spec["model"]), int(spec["devices"])
|
|
1163
|
+
|
|
1164
|
+
def retired_reason(self, name: str) -> str | None:
|
|
1165
|
+
"""Why Kaggle no longer serves a card it once did. Spec "Kaggle", the accelerators."""
|
|
1166
|
+
return RETIRED.get(name.upper())
|
|
1167
|
+
|
|
1028
1168
|
def store_backend(self) -> str:
|
|
1029
1169
|
return "filesystem"
|
|
1030
1170
|
|
|
@@ -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
|
|
@@ -138,7 +138,7 @@ dependencies = [
|
|
|
138
138
|
|
|
139
139
|
[[package]]
|
|
140
140
|
name = "letify-agent"
|
|
141
|
-
version = "2.
|
|
141
|
+
version = "2.3.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.3.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.3.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.3.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.3.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.3.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):
|
|
@@ -295,7 +295,7 @@ def kaggle_provider():
|
|
|
295
295
|
return provider_of(Kaggle, "kaggle_a")
|
|
296
296
|
|
|
297
297
|
|
|
298
|
-
@pytest.mark.parametrize("name, devices", [("T4", 2)
|
|
298
|
+
@pytest.mark.parametrize("name, devices", [("T4", 2)])
|
|
299
299
|
def test_a_kaggle_gpu_instance_declares_its_card_count(name, devices) -> None:
|
|
300
300
|
"""Spec "Kaggle": the stable GPU names carry their session's card count."""
|
|
301
301
|
instance = getattr(kaggle_provider(), name)
|
|
@@ -460,7 +460,7 @@ def test_the_online_check_is_not_repeated_within_the_liveness_ttl(fake_kaggle) -
|
|
|
460
460
|
def test_declaring_host_local_on_kaggle_fails_at_decoration(let) -> None:
|
|
461
461
|
import letify
|
|
462
462
|
|
|
463
|
-
device = kaggle_provider().
|
|
463
|
+
device = kaggle_provider().T4
|
|
464
464
|
with pytest.raises(letify.UnsupportedMode, match="host='remote'"):
|
|
465
465
|
|
|
466
466
|
@let.function(device=device)
|
|
@@ -488,7 +488,6 @@ def test_the_generated_types_mark_kaggle_accelerators_remote_only(isolated_home)
|
|
|
488
488
|
(isolated_home / ".letify" / "config.toml").write_text('[kg]\nkind = "kaggle"\n')
|
|
489
489
|
text = stubs.render(letify.Launcher(announce=False))
|
|
490
490
|
body = text.split("class Kg(")[1].split("\nclass ")[0]
|
|
491
|
-
assert " P100: letify.declare.instance.RemoteOnlyInstance" in body
|
|
492
491
|
assert ": Instance" not in body
|
|
493
492
|
|
|
494
493
|
|
|
@@ -560,7 +559,6 @@ def session_channel(fake_kaggle, name: str = "letify-t4-1"):
|
|
|
560
559
|
@pytest.mark.parametrize("name, accelerator", [
|
|
561
560
|
("TPU_V3_8", "TPU_V3_8"),
|
|
562
561
|
("T4", "NVIDIA_TESLA_T4"),
|
|
563
|
-
("P100", "NVIDIA_TESLA_P100"),
|
|
564
562
|
("CPU", None),
|
|
565
563
|
])
|
|
566
564
|
def test_a_kaggle_instance_requests_its_declared_accelerator(
|
|
@@ -1124,3 +1122,113 @@ def test_a_stored_token_with_a_newline_authenticates_cli_deletion(
|
|
|
1124
1122
|
assert delete_notebook_via_cli("kaggle_a", "notebook63516d2758") is True
|
|
1125
1123
|
assert fake_kaggle_cli.calls[0][-1] == "irack000/notebook63516d2758"
|
|
1126
1124
|
assert fake_kaggle_cli.environments[0]["KAGGLE_API_TOKEN"] == API_TOKEN
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def test_a_kaggle_session_states_the_cards_it_requires() -> None:
|
|
1128
|
+
"""Spec "Kaggle": T4 expects two cards whose model contains T4."""
|
|
1129
|
+
from letify.declare.instance import Instance
|
|
1130
|
+
|
|
1131
|
+
provider = kaggle_provider()
|
|
1132
|
+
assert provider.expected_cards(Instance(provider, gpu="T4")) == ("T4", 2)
|
|
1133
|
+
assert provider.expected_cards(Instance(provider, gpu=None)) is None
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def test_a_card_that_is_not_the_one_asked_for_is_refused(monkeypatch) -> None:
|
|
1137
|
+
"""Spec "Kaggle": a request answered with another card does not run.
|
|
1138
|
+
|
|
1139
|
+
Kaggle answered two live P100 requests with two T4s, which is how a declaration
|
|
1140
|
+
stopped saying where the function runs.
|
|
1141
|
+
"""
|
|
1142
|
+
from letify.errors import InsufficientDevices
|
|
1143
|
+
from letify.runtime.session import Runtime
|
|
1144
|
+
|
|
1145
|
+
provider = kaggle_provider()
|
|
1146
|
+
session = object.__new__(Runtime)
|
|
1147
|
+
session.name = "letify-t4-1"
|
|
1148
|
+
session.provider = provider
|
|
1149
|
+
session.instance = type("I", (), {"gpu": "T4", "accelerator": "T4"})()
|
|
1150
|
+
monkeypatch.setattr(Runtime, "eval", lambda self, source, timeout=None: ["Tesla P100"])
|
|
1151
|
+
with pytest.raises(InsufficientDevices) as failure:
|
|
1152
|
+
session.check_devices()
|
|
1153
|
+
message = str(failure.value)
|
|
1154
|
+
assert "2 x T4" in message
|
|
1155
|
+
assert "Tesla P100" in message
|
|
1156
|
+
assert "any_accelerator" in message
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def test_the_cards_that_were_asked_for_pass(monkeypatch) -> None:
|
|
1160
|
+
"""Spec "Kaggle": the right name and the right count is what passes."""
|
|
1161
|
+
from letify.runtime.session import Runtime
|
|
1162
|
+
|
|
1163
|
+
provider = kaggle_provider()
|
|
1164
|
+
session = object.__new__(Runtime)
|
|
1165
|
+
session.name = "letify-t4-2"
|
|
1166
|
+
session.provider = provider
|
|
1167
|
+
session.instance = type("I", (), {"gpu": "T4", "accelerator": "T4"})()
|
|
1168
|
+
monkeypatch.setattr(
|
|
1169
|
+
Runtime, "eval", lambda self, source, timeout=None: ["Tesla T4", "Tesla T4"]
|
|
1170
|
+
)
|
|
1171
|
+
session.check_devices()
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def test_the_notebooks_letify_recorded_are_listed_and_the_user_s_own_are_not(
|
|
1175
|
+
fake_kaggle_cli, kaggle_api_token, config_file
|
|
1176
|
+
) -> None:
|
|
1177
|
+
"""Spec "Kaggle", finding the notebooks letify owns.
|
|
1178
|
+
|
|
1179
|
+
Kaggle names the notebook itself, confirmed on a live account where one letify created
|
|
1180
|
+
was titled `notebook8843ff70eb`, so the local record is what identifies them. A process
|
|
1181
|
+
that dies before stop leaves its notebook holding weekly quota with nothing pointing at
|
|
1182
|
+
it, and this is how it is found.
|
|
1183
|
+
"""
|
|
1184
|
+
from letify.providers.kaggle import record_notebook
|
|
1185
|
+
|
|
1186
|
+
provider = kaggle_provider()
|
|
1187
|
+
assert provider.discovers_sessions is True
|
|
1188
|
+
record_notebook("kaggle_a", "letify-runtime")
|
|
1189
|
+
record_notebook("kaggle_a", "letify-runtime-2")
|
|
1190
|
+
assert provider.sessions() == ["irack000/letify-runtime", "irack000/letify-runtime-2"]
|
|
1191
|
+
listed = [call for call in fake_kaggle_cli.calls if "list" in call]
|
|
1192
|
+
assert listed and "--mine" in listed[0]
|
|
1193
|
+
assert fake_kaggle_cli.environments[0].get("KAGGLE_API_TOKEN")
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def test_a_notebook_deleted_elsewhere_is_dropped_from_the_record(
|
|
1197
|
+
fake_kaggle_cli, kaggle_api_token, config_file
|
|
1198
|
+
) -> None:
|
|
1199
|
+
"""Spec: a ref the account no longer has was deleted elsewhere, so it is not reported."""
|
|
1200
|
+
from letify.providers.kaggle import record_notebook, recorded_notebooks
|
|
1201
|
+
|
|
1202
|
+
provider = kaggle_provider()
|
|
1203
|
+
record_notebook("kaggle_a", "letify-runtime")
|
|
1204
|
+
record_notebook("kaggle_a", "gone-already")
|
|
1205
|
+
assert provider.sessions() == ["irack000/letify-runtime"]
|
|
1206
|
+
assert recorded_notebooks("kaggle_a") == ["irack000/letify-runtime"]
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
def test_nothing_is_reported_when_letify_recorded_nothing(
|
|
1210
|
+
fake_kaggle_cli, kaggle_api_token, config_file
|
|
1211
|
+
) -> None:
|
|
1212
|
+
"""Spec: a notebook created before letify kept this record is not in it."""
|
|
1213
|
+
assert kaggle_provider().sessions() == []
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def test_listing_notebooks_without_an_api_token_is_empty_rather_than_a_failure(
|
|
1217
|
+
fake_kaggle_cli, isolated_home, config_file
|
|
1218
|
+
) -> None:
|
|
1219
|
+
"""Spec: reading what exists must not fail a command that is asking what exists."""
|
|
1220
|
+
from letify.providers.kaggle import notebook_record
|
|
1221
|
+
|
|
1222
|
+
path = notebook_record("kaggle_a")
|
|
1223
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
1224
|
+
path.write_text("irack000/letify-runtime\n", encoding="utf-8")
|
|
1225
|
+
assert kaggle_provider().sessions() == []
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def test_a_kaggle_account_is_asked_for_its_sessions(launcher_from, fake_kaggle_cli) -> None:
|
|
1229
|
+
"""Spec: whether an account can be asked is the provider's answer, not the launcher's."""
|
|
1230
|
+
let = launcher_from('[kaggle_a]\nkind = "kaggle"\n')
|
|
1231
|
+
rows = {row["alias"]: row for row in let.sessions()}
|
|
1232
|
+
row = rows.get("kaggle_a")
|
|
1233
|
+
assert row is not None, rows
|
|
1234
|
+
assert row.get("reason") != "session discovery is not supported"
|
|
@@ -299,3 +299,21 @@ def test_a_kaggle_session_on_the_bridge_offers_no_transfer_channel() -> None:
|
|
|
299
299
|
assert provider.link(Runtime()) is None
|
|
300
300
|
with pytest.raises(UnsupportedMode):
|
|
301
301
|
provider.transfer_channel(Runtime())
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_kaggle_does_not_offer_the_retired_p100() -> None:
|
|
305
|
+
"""Spec "Kaggle": Kaggle retired the P100 on 2026-09-15 and substitutes T4x2.
|
|
306
|
+
|
|
307
|
+
Two live accounts asking for P100 both received two T4s. A declaration states where a
|
|
308
|
+
function runs, so the name is refused rather than silently answered with another card.
|
|
309
|
+
"""
|
|
310
|
+
from letify.errors import UnknownInstance
|
|
311
|
+
|
|
312
|
+
provider = kaggle()
|
|
313
|
+
assert "P100" not in provider.discover()
|
|
314
|
+
assert "T4" in provider.discover()
|
|
315
|
+
with pytest.raises(UnknownInstance) as failure:
|
|
316
|
+
provider.device("P100")
|
|
317
|
+
message = str(failure.value)
|
|
318
|
+
assert "2026-09-15" in message
|
|
319
|
+
assert "T4" in message
|