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.
Files changed (200) hide show
  1. {letify-2.2.0 → letify-2.3.0}/PKG-INFO +1 -1
  2. {letify-2.2.0 → letify-2.3.0}/letify/__init__.py +1 -1
  3. {letify-2.2.0 → letify-2.3.0}/letify/launcher.py +1 -1
  4. {letify-2.2.0 → letify-2.3.0}/letify/providers/base.py +38 -0
  5. {letify-2.2.0 → letify-2.3.0}/letify/providers/colab.py +2 -0
  6. {letify-2.2.0 → letify-2.3.0}/letify/providers/kaggle.py +143 -3
  7. {letify-2.2.0 → letify-2.3.0}/letify/runtime/bootstrap.py +27 -0
  8. {letify-2.2.0 → letify-2.3.0}/letify/runtime/session.py +38 -0
  9. {letify-2.2.0 → letify-2.3.0}/letify-core/Cargo.lock +3 -3
  10. {letify-2.2.0 → letify-2.3.0}/letify-core/Cargo.toml +1 -1
  11. {letify-2.2.0 → letify-2.3.0}/letify-ext/package-lock.json +2 -2
  12. {letify-2.2.0 → letify-2.3.0}/letify-ext/package.json +1 -1
  13. {letify-2.2.0 → letify-2.3.0}/pyproject.toml +1 -1
  14. {letify-2.2.0 → letify-2.3.0}/tests/conftest.py +11 -0
  15. {letify-2.2.0 → letify-2.3.0}/tests/test_elice.py +5 -1
  16. {letify-2.2.0 → letify-2.3.0}/tests/test_kaggle.py +112 -4
  17. {letify-2.2.0 → letify-2.3.0}/tests/test_kaggle_link.py +18 -0
  18. {letify-2.2.0 → letify-2.3.0}/tests/test_runtime.py +32 -1
  19. {letify-2.2.0 → letify-2.3.0}/.github/workflows/ci.yml +0 -0
  20. {letify-2.2.0 → letify-2.3.0}/.github/workflows/docs.yml +0 -0
  21. {letify-2.2.0 → letify-2.3.0}/.github/workflows/publish.yml +0 -0
  22. {letify-2.2.0 → letify-2.3.0}/.gitignore +0 -0
  23. {letify-2.2.0 → letify-2.3.0}/.researchtree +0 -0
  24. {letify-2.2.0 → letify-2.3.0}/LICENSE +0 -0
  25. {letify-2.2.0 → letify-2.3.0}/README.md +0 -0
  26. {letify-2.2.0 → letify-2.3.0}/docs/guide/01-getting-started.md +0 -0
  27. {letify-2.2.0 → letify-2.3.0}/docs/guide/02-providers.md +0 -0
  28. {letify-2.2.0 → letify-2.3.0}/docs/guide/03-execution-modes.md +0 -0
  29. {letify-2.2.0 → letify-2.3.0}/docs/guide/04-environments-and-data.md +0 -0
  30. {letify-2.2.0 → letify-2.3.0}/docs/guide/05-concurrency.md +0 -0
  31. {letify-2.2.0 → letify-2.3.0}/docs/guide/06-cost.md +0 -0
  32. {letify-2.2.0 → letify-2.3.0}/docs/guide/07-troubleshooting.md +0 -0
  33. {letify-2.2.0 → letify-2.3.0}/docs/guide/08-releasing.md +0 -0
  34. {letify-2.2.0 → letify-2.3.0}/docs/guide/README.md +0 -0
  35. {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
  36. {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
  37. {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
  38. {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
  39. {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
  40. {letify-2.2.0 → letify-2.3.0}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  41. {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
  42. {letify-2.2.0 → letify-2.3.0}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  43. {letify-2.2.0 → letify-2.3.0}/docs/guide/ko/README.md +0 -0
  44. {letify-2.2.0 → letify-2.3.0}/docs/locales/README_ko.md +0 -0
  45. {letify-2.2.0 → letify-2.3.0}/docs/mkdocs.yml +0 -0
  46. {letify-2.2.0 → letify-2.3.0}/examples/README.md +0 -0
  47. {letify-2.2.0 → letify-2.3.0}/examples/nvfp4-lora/.letify.example +0 -0
  48. {letify-2.2.0 → letify-2.3.0}/examples/nvfp4-lora/00_smoke.py +0 -0
  49. {letify-2.2.0 → letify-2.3.0}/examples/nvfp4-lora/01_sweep.py +0 -0
  50. {letify-2.2.0 → letify-2.3.0}/examples/nvfp4-lora/02_resume.py +0 -0
  51. {letify-2.2.0 → letify-2.3.0}/examples/nvfp4-lora/03_watch.py +0 -0
  52. {letify-2.2.0 → letify-2.3.0}/examples/nvfp4-lora/README.md +0 -0
  53. {letify-2.2.0 → letify-2.3.0}/examples/nvfp4-lora/README_ko.md +0 -0
  54. {letify-2.2.0 → letify-2.3.0}/examples/nvfp4-lora/common.py +0 -0
  55. {letify-2.2.0 → letify-2.3.0}/examples/nvfp4-lora/recipe.py +0 -0
  56. {letify-2.2.0 → letify-2.3.0}/letify/cli.py +0 -0
  57. {letify-2.2.0 → letify-2.3.0}/letify/config/__init__.py +0 -0
  58. {letify-2.2.0 → letify-2.3.0}/letify/config/inventory.py +0 -0
  59. {letify-2.2.0 → letify-2.3.0}/letify/config/login.py +0 -0
  60. {letify-2.2.0 → letify-2.3.0}/letify/config/schema.py +0 -0
  61. {letify-2.2.0 → letify-2.3.0}/letify/config/secrets.py +0 -0
  62. {letify-2.2.0 → letify-2.3.0}/letify/config/writer.py +0 -0
  63. {letify-2.2.0 → letify-2.3.0}/letify/declare/__init__.py +0 -0
  64. {letify-2.2.0 → letify-2.3.0}/letify/declare/cache.py +0 -0
  65. {letify-2.2.0 → letify-2.3.0}/letify/declare/env.py +0 -0
  66. {letify-2.2.0 → letify-2.3.0}/letify/declare/function.py +0 -0
  67. {letify-2.2.0 → letify-2.3.0}/letify/declare/instance.py +0 -0
  68. {letify-2.2.0 → letify-2.3.0}/letify/errors.py +0 -0
  69. {letify-2.2.0 → letify-2.3.0}/letify/install.py +0 -0
  70. {letify-2.2.0 → letify-2.3.0}/letify/paths.py +0 -0
  71. {letify-2.2.0 → letify-2.3.0}/letify/protocol/__init__.py +0 -0
  72. {letify-2.2.0 → letify-2.3.0}/letify/protocol/codec.py +0 -0
  73. {letify-2.2.0 → letify-2.3.0}/letify/protocol/driver.py +0 -0
  74. {letify-2.2.0 → letify-2.3.0}/letify/protocol/handle.py +0 -0
  75. {letify-2.2.0 → letify-2.3.0}/letify/protocol/wire.py +0 -0
  76. {letify-2.2.0 → letify-2.3.0}/letify/protocol/worker.py +0 -0
  77. {letify-2.2.0 → letify-2.3.0}/letify/providers/__init__.py +0 -0
  78. {letify-2.2.0 → letify-2.3.0}/letify/providers/colab_files.py +0 -0
  79. {letify-2.2.0 → letify-2.3.0}/letify/providers/colab_keepalive.py +0 -0
  80. {letify-2.2.0 → letify-2.3.0}/letify/providers/elice.py +0 -0
  81. {letify-2.2.0 → letify-2.3.0}/letify/providers/kaggle_adapter.py +0 -0
  82. {letify-2.2.0 → letify-2.3.0}/letify/providers/local.py +0 -0
  83. {letify-2.2.0 → letify-2.3.0}/letify/providers/modal.py +0 -0
  84. {letify-2.2.0 → letify-2.3.0}/letify/providers/modal_adapter.py +0 -0
  85. {letify-2.2.0 → letify-2.3.0}/letify/providers/naming.py +0 -0
  86. {letify-2.2.0 → letify-2.3.0}/letify/providers/shell.py +0 -0
  87. {letify-2.2.0 → letify-2.3.0}/letify/providers/tunnel.py +0 -0
  88. {letify-2.2.0 → letify-2.3.0}/letify/providers/usage.py +0 -0
  89. {letify-2.2.0 → letify-2.3.0}/letify/remoting/__init__.py +0 -0
  90. {letify-2.2.0 → letify-2.3.0}/letify/remoting/capability.py +0 -0
  91. {letify-2.2.0 → letify-2.3.0}/letify/remoting/device/__init__.py +0 -0
  92. {letify-2.2.0 → letify-2.3.0}/letify/remoting/device/client.py +0 -0
  93. {letify-2.2.0 → letify-2.3.0}/letify/remoting/device/cuda.py +0 -0
  94. {letify-2.2.0 → letify-2.3.0}/letify/remoting/device/executor.py +0 -0
  95. {letify-2.2.0 → letify-2.3.0}/letify/remoting/device/frames.py +0 -0
  96. {letify-2.2.0 → letify-2.3.0}/letify/remoting/device/guard.py +0 -0
  97. {letify-2.2.0 → letify-2.3.0}/letify/remoting/device/tensor.py +0 -0
  98. {letify-2.2.0 → letify-2.3.0}/letify/remoting/device/trace.py +0 -0
  99. {letify-2.2.0 → letify-2.3.0}/letify/remoting/device/value.py +0 -0
  100. {letify-2.2.0 → letify-2.3.0}/letify/remoting/loader.py +0 -0
  101. {letify-2.2.0 → letify-2.3.0}/letify/remoting/probe.py +0 -0
  102. {letify-2.2.0 → letify-2.3.0}/letify/render.py +0 -0
  103. {letify-2.2.0 → letify-2.3.0}/letify/runtime/__init__.py +0 -0
  104. {letify-2.2.0 → letify-2.3.0}/letify/runtime/channel.py +0 -0
  105. {letify-2.2.0 → letify-2.3.0}/letify/runtime/lease.py +0 -0
  106. {letify-2.2.0 → letify-2.3.0}/letify/runtime/pool.py +0 -0
  107. {letify-2.2.0 → letify-2.3.0}/letify/runtime/telemetry.py +0 -0
  108. {letify-2.2.0 → letify-2.3.0}/letify/store/__init__.py +0 -0
  109. {letify-2.2.0 → letify-2.3.0}/letify/store/backends/__init__.py +0 -0
  110. {letify-2.2.0 → letify-2.3.0}/letify/store/backends/filesystem.py +0 -0
  111. {letify-2.2.0 → letify-2.3.0}/letify/store/backends/google_auth.py +0 -0
  112. {letify-2.2.0 → letify-2.3.0}/letify/store/backends/layout.py +0 -0
  113. {letify-2.2.0 → letify-2.3.0}/letify/store/backends/objects.py +0 -0
  114. {letify-2.2.0 → letify-2.3.0}/letify/store/cas.py +0 -0
  115. {letify-2.2.0 → letify-2.3.0}/letify/store/pathdata.py +0 -0
  116. {letify-2.2.0 → letify-2.3.0}/letify/store/sendorder.py +0 -0
  117. {letify-2.2.0 → letify-2.3.0}/letify/store/volume.py +0 -0
  118. {letify-2.2.0 → letify-2.3.0}/letify/stubs.py +0 -0
  119. {letify-2.2.0 → letify-2.3.0}/letify/tools.py +0 -0
  120. {letify-2.2.0 → letify-2.3.0}/letify/transport/__init__.py +0 -0
  121. {letify-2.2.0 → letify-2.3.0}/letify/transport/agent.py +0 -0
  122. {letify-2.2.0 → letify-2.3.0}/letify/transport/announce.py +0 -0
  123. {letify-2.2.0 → letify-2.3.0}/letify/transport/link.py +0 -0
  124. {letify-2.2.0 → letify-2.3.0}/letify/transport/nat.py +0 -0
  125. {letify-2.2.0 → letify-2.3.0}/letify/transport/pipeline.py +0 -0
  126. {letify-2.2.0 → letify-2.3.0}/letify/transport/portmap.py +0 -0
  127. {letify-2.2.0 → letify-2.3.0}/letify/transport/probe.py +0 -0
  128. {letify-2.2.0 → letify-2.3.0}/letify/transport/quic.py +0 -0
  129. {letify-2.2.0 → letify-2.3.0}/letify/transport/rendezvous.py +0 -0
  130. {letify-2.2.0 → letify-2.3.0}/letify/transport/setup.py +0 -0
  131. {letify-2.2.0 → letify-2.3.0}/letify/transport/sshopts.py +0 -0
  132. {letify-2.2.0 → letify-2.3.0}/letify/transport/strategies.py +0 -0
  133. {letify-2.2.0 → letify-2.3.0}/letify-core/.gitignore +0 -0
  134. {letify-2.2.0 → letify-2.3.0}/letify-core/build.py +0 -0
  135. {letify-2.2.0 → letify-2.3.0}/letify-core/letify-agent/Cargo.toml +0 -0
  136. {letify-2.2.0 → letify-2.3.0}/letify-core/letify-agent/src/driver.rs +0 -0
  137. {letify-2.2.0 → letify-2.3.0}/letify-core/letify-agent/src/main.rs +0 -0
  138. {letify-2.2.0 → letify-2.3.0}/letify-core/letify-driver/Cargo.toml +0 -0
  139. {letify-2.2.0 → letify-2.3.0}/letify-core/letify-driver/src/client.rs +0 -0
  140. {letify-2.2.0 → letify-2.3.0}/letify-core/letify-driver/src/exports.rs +0 -0
  141. {letify-2.2.0 → letify-2.3.0}/letify-core/letify-driver/src/image.rs +0 -0
  142. {letify-2.2.0 → letify-2.3.0}/letify-core/letify-driver/src/lib.rs +0 -0
  143. {letify-2.2.0 → letify-2.3.0}/letify-core/letify-driver/src/status.rs +0 -0
  144. {letify-2.2.0 → letify-2.3.0}/letify-core/letify-driver/src/table.rs +0 -0
  145. {letify-2.2.0 → letify-2.3.0}/letify-core/letify-quic/Cargo.toml +0 -0
  146. {letify-2.2.0 → letify-2.3.0}/letify-core/letify-quic/src/main.rs +0 -0
  147. {letify-2.2.0 → letify-2.3.0}/letify-core/letify-wire/Cargo.toml +0 -0
  148. {letify-2.2.0 → letify-2.3.0}/letify-core/letify-wire/src/lib.rs +0 -0
  149. {letify-2.2.0 → letify-2.3.0}/letify-core/letify-wire/tests/throughput.rs +0 -0
  150. {letify-2.2.0 → letify-2.3.0}/letify-core/rust-toolchain.toml +0 -0
  151. {letify-2.2.0 → letify-2.3.0}/letify-ext/.vscodeignore +0 -0
  152. {letify-2.2.0 → letify-2.3.0}/letify-ext/LICENSE +0 -0
  153. {letify-2.2.0 → letify-2.3.0}/letify-ext/README.md +0 -0
  154. {letify-2.2.0 → letify-2.3.0}/letify-ext/media/icon.svg +0 -0
  155. {letify-2.2.0 → letify-2.3.0}/letify-ext/src/cli.ts +0 -0
  156. {letify-2.2.0 → letify-2.3.0}/letify-ext/src/extension.ts +0 -0
  157. {letify-2.2.0 → letify-2.3.0}/letify-ext/src/history.ts +0 -0
  158. {letify-2.2.0 → letify-2.3.0}/letify-ext/src/model.ts +0 -0
  159. {letify-2.2.0 → letify-2.3.0}/letify-ext/src/poll.ts +0 -0
  160. {letify-2.2.0 → letify-2.3.0}/letify-ext/src/render.ts +0 -0
  161. {letify-2.2.0 → letify-2.3.0}/letify-ext/test/cli.test.ts +0 -0
  162. {letify-2.2.0 → letify-2.3.0}/letify-ext/test/manifest.test.ts +0 -0
  163. {letify-2.2.0 → letify-2.3.0}/letify-ext/test/model.test.ts +0 -0
  164. {letify-2.2.0 → letify-2.3.0}/letify-ext/test/regressions.test.ts +0 -0
  165. {letify-2.2.0 → letify-2.3.0}/letify-ext/tsconfig.json +0 -0
  166. {letify-2.2.0 → letify-2.3.0}/letify_providers.py +0 -0
  167. {letify-2.2.0 → letify-2.3.0}/scripts/sync-main.sh +0 -0
  168. {letify-2.2.0 → letify-2.3.0}/scripts/version.py +0 -0
  169. {letify-2.2.0 → letify-2.3.0}/tests/fake_kaggle_adapter.py +0 -0
  170. {letify-2.2.0 → letify-2.3.0}/tests/fake_modal_adapter.py +0 -0
  171. {letify-2.2.0 → letify-2.3.0}/tests/test_cli.py +0 -0
  172. {letify-2.2.0 → letify-2.3.0}/tests/test_cli_output.py +0 -0
  173. {letify-2.2.0 → letify-2.3.0}/tests/test_colab_keepalive.py +0 -0
  174. {letify-2.2.0 → letify-2.3.0}/tests/test_config.py +0 -0
  175. {letify-2.2.0 → letify-2.3.0}/tests/test_core.py +0 -0
  176. {letify-2.2.0 → letify-2.3.0}/tests/test_data.py +0 -0
  177. {letify-2.2.0 → letify-2.3.0}/tests/test_data_streaming.py +0 -0
  178. {letify-2.2.0 → letify-2.3.0}/tests/test_declare.py +0 -0
  179. {letify-2.2.0 → letify-2.3.0}/tests/test_device.py +0 -0
  180. {letify-2.2.0 → letify-2.3.0}/tests/test_editor_sessions.py +0 -0
  181. {letify-2.2.0 → letify-2.3.0}/tests/test_frames.py +0 -0
  182. {letify-2.2.0 → letify-2.3.0}/tests/test_install.py +0 -0
  183. {letify-2.2.0 → letify-2.3.0}/tests/test_kaggle_cookie.py +0 -0
  184. {letify-2.2.0 → letify-2.3.0}/tests/test_link_log.py +0 -0
  185. {letify-2.2.0 → letify-2.3.0}/tests/test_notebook.py +0 -0
  186. {letify-2.2.0 → letify-2.3.0}/tests/test_parallel_placement.py +0 -0
  187. {letify-2.2.0 → letify-2.3.0}/tests/test_paths.py +0 -0
  188. {letify-2.2.0 → letify-2.3.0}/tests/test_portmap.py +0 -0
  189. {letify-2.2.0 → letify-2.3.0}/tests/test_protocol.py +0 -0
  190. {letify-2.2.0 → letify-2.3.0}/tests/test_providers.py +0 -0
  191. {letify-2.2.0 → letify-2.3.0}/tests/test_quic.py +0 -0
  192. {letify-2.2.0 → letify-2.3.0}/tests/test_remoting.py +0 -0
  193. {letify-2.2.0 → letify-2.3.0}/tests/test_render.py +0 -0
  194. {letify-2.2.0 → letify-2.3.0}/tests/test_ship.py +0 -0
  195. {letify-2.2.0 → letify-2.3.0}/tests/test_store.py +0 -0
  196. {letify-2.2.0 → letify-2.3.0}/tests/test_strategies.py +0 -0
  197. {letify-2.2.0 → letify-2.3.0}/tests/test_stubs.py +0 -0
  198. {letify-2.2.0 → letify-2.3.0}/tests/test_transport.py +0 -0
  199. {letify-2.2.0 → letify-2.3.0}/tests/test_tunnel_setup.py +0 -0
  200. {letify-2.2.0 → letify-2.3.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Declarations that become infrastructure.
5
5
  Project-URL: Homepage, https://github.com/DarkPyonix/letify
6
6
  Project-URL: Documentation, https://github.com/DarkPyonix/letify/tree/main/docs
@@ -62,7 +62,7 @@ remote: Final = Host.remote
62
62
  # The enum class stays importable from letify.declare.instance for letify's own use.
63
63
  del Host
64
64
 
65
- __version__ = "2.2.0"
65
+ __version__ = "2.3.0"
66
66
 
67
67
 
68
68
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -486,7 +486,7 @@ class Launcher:
486
486
  for alias in self.config.order:
487
487
  try:
488
488
  provider = self.provider(alias)
489
- supported = provider.kind == "colab"
489
+ supported = provider.discovers_sessions
490
490
  rows.append(
491
491
  {
492
492
  "alias": alias,
@@ -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)
@@ -300,6 +300,8 @@ class Colab(Shell):
300
300
  stderr=exc.stderr,
301
301
  ) from exc
302
302
 
303
+ discovers_sessions = True
304
+
303
305
  def sessions(self) -> list[str]:
304
306
  """Names of the sessions this account currently holds."""
305
307
  names = []
@@ -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
- "P100": {"vram_gb": 16, "devices": 1, "accelerator": "NVIDIA_TESLA_P100"},
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.2.0"
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.2.0"
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.2.0"
167
+ version = "2.3.0"
168
168
 
169
169
  [[package]]
170
170
  name = "libc"
@@ -8,7 +8,7 @@ resolver = "3"
8
8
  members = ["letify-wire", "letify-driver", "letify-agent", "letify-quic"]
9
9
 
10
10
  [workspace.package]
11
- version = "2.2.0"
11
+ version = "2.3.0"
12
12
  edition = "2024"
13
13
  license = "MIT"
14
14
  repository = "https://github.com/DarkPyonix/letify"
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "letify-status",
3
- "version": "2.2.0",
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.2.0",
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.2.0",
5
+ "version": "2.3.0",
6
6
  "publisher": "darkpyonix",
7
7
  "license": "SEE LICENSE IN LICENSE",
8
8
  "repository": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "letify"
3
- version = "2.2.0"
3
+ version = "2.3.0"
4
4
  description = "Declarations that become infrastructure."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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
- Path("uv.lock").write_text("", encoding="utf-8")
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), ("P100", 1)])
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().P100
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