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.
Files changed (200) hide show
  1. {letify-2.2.0 → letify-2.4.0}/PKG-INFO +1 -1
  2. {letify-2.2.0 → letify-2.4.0}/letify/__init__.py +1 -1
  3. {letify-2.2.0 → letify-2.4.0}/letify/launcher.py +1 -1
  4. {letify-2.2.0 → letify-2.4.0}/letify/providers/base.py +49 -0
  5. {letify-2.2.0 → letify-2.4.0}/letify/providers/colab.py +2 -0
  6. {letify-2.2.0 → letify-2.4.0}/letify/providers/kaggle.py +170 -5
  7. {letify-2.2.0 → letify-2.4.0}/letify/runtime/bootstrap.py +27 -0
  8. {letify-2.2.0 → letify-2.4.0}/letify/runtime/session.py +38 -0
  9. {letify-2.2.0 → letify-2.4.0}/letify/transport/pipeline.py +44 -0
  10. {letify-2.2.0 → letify-2.4.0}/letify-core/Cargo.lock +3 -3
  11. {letify-2.2.0 → letify-2.4.0}/letify-core/Cargo.toml +1 -1
  12. {letify-2.2.0 → letify-2.4.0}/letify-ext/package-lock.json +2 -2
  13. {letify-2.2.0 → letify-2.4.0}/letify-ext/package.json +1 -1
  14. {letify-2.2.0 → letify-2.4.0}/pyproject.toml +1 -1
  15. {letify-2.2.0 → letify-2.4.0}/tests/conftest.py +11 -0
  16. {letify-2.2.0 → letify-2.4.0}/tests/test_elice.py +5 -1
  17. {letify-2.2.0 → letify-2.4.0}/tests/test_kaggle.py +174 -4
  18. {letify-2.2.0 → letify-2.4.0}/tests/test_kaggle_link.py +18 -0
  19. {letify-2.2.0 → letify-2.4.0}/tests/test_runtime.py +32 -1
  20. {letify-2.2.0 → letify-2.4.0}/tests/test_transport.py +32 -0
  21. {letify-2.2.0 → letify-2.4.0}/.github/workflows/ci.yml +0 -0
  22. {letify-2.2.0 → letify-2.4.0}/.github/workflows/docs.yml +0 -0
  23. {letify-2.2.0 → letify-2.4.0}/.github/workflows/publish.yml +0 -0
  24. {letify-2.2.0 → letify-2.4.0}/.gitignore +0 -0
  25. {letify-2.2.0 → letify-2.4.0}/.researchtree +0 -0
  26. {letify-2.2.0 → letify-2.4.0}/LICENSE +0 -0
  27. {letify-2.2.0 → letify-2.4.0}/README.md +0 -0
  28. {letify-2.2.0 → letify-2.4.0}/docs/guide/01-getting-started.md +0 -0
  29. {letify-2.2.0 → letify-2.4.0}/docs/guide/02-providers.md +0 -0
  30. {letify-2.2.0 → letify-2.4.0}/docs/guide/03-execution-modes.md +0 -0
  31. {letify-2.2.0 → letify-2.4.0}/docs/guide/04-environments-and-data.md +0 -0
  32. {letify-2.2.0 → letify-2.4.0}/docs/guide/05-concurrency.md +0 -0
  33. {letify-2.2.0 → letify-2.4.0}/docs/guide/06-cost.md +0 -0
  34. {letify-2.2.0 → letify-2.4.0}/docs/guide/07-troubleshooting.md +0 -0
  35. {letify-2.2.0 → letify-2.4.0}/docs/guide/08-releasing.md +0 -0
  36. {letify-2.2.0 → letify-2.4.0}/docs/guide/README.md +0 -0
  37. {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
  38. {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
  39. {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
  40. {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
  41. {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
  42. {letify-2.2.0 → letify-2.4.0}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  43. {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
  44. {letify-2.2.0 → letify-2.4.0}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  45. {letify-2.2.0 → letify-2.4.0}/docs/guide/ko/README.md +0 -0
  46. {letify-2.2.0 → letify-2.4.0}/docs/locales/README_ko.md +0 -0
  47. {letify-2.2.0 → letify-2.4.0}/docs/mkdocs.yml +0 -0
  48. {letify-2.2.0 → letify-2.4.0}/examples/README.md +0 -0
  49. {letify-2.2.0 → letify-2.4.0}/examples/nvfp4-lora/.letify.example +0 -0
  50. {letify-2.2.0 → letify-2.4.0}/examples/nvfp4-lora/00_smoke.py +0 -0
  51. {letify-2.2.0 → letify-2.4.0}/examples/nvfp4-lora/01_sweep.py +0 -0
  52. {letify-2.2.0 → letify-2.4.0}/examples/nvfp4-lora/02_resume.py +0 -0
  53. {letify-2.2.0 → letify-2.4.0}/examples/nvfp4-lora/03_watch.py +0 -0
  54. {letify-2.2.0 → letify-2.4.0}/examples/nvfp4-lora/README.md +0 -0
  55. {letify-2.2.0 → letify-2.4.0}/examples/nvfp4-lora/README_ko.md +0 -0
  56. {letify-2.2.0 → letify-2.4.0}/examples/nvfp4-lora/common.py +0 -0
  57. {letify-2.2.0 → letify-2.4.0}/examples/nvfp4-lora/recipe.py +0 -0
  58. {letify-2.2.0 → letify-2.4.0}/letify/cli.py +0 -0
  59. {letify-2.2.0 → letify-2.4.0}/letify/config/__init__.py +0 -0
  60. {letify-2.2.0 → letify-2.4.0}/letify/config/inventory.py +0 -0
  61. {letify-2.2.0 → letify-2.4.0}/letify/config/login.py +0 -0
  62. {letify-2.2.0 → letify-2.4.0}/letify/config/schema.py +0 -0
  63. {letify-2.2.0 → letify-2.4.0}/letify/config/secrets.py +0 -0
  64. {letify-2.2.0 → letify-2.4.0}/letify/config/writer.py +0 -0
  65. {letify-2.2.0 → letify-2.4.0}/letify/declare/__init__.py +0 -0
  66. {letify-2.2.0 → letify-2.4.0}/letify/declare/cache.py +0 -0
  67. {letify-2.2.0 → letify-2.4.0}/letify/declare/env.py +0 -0
  68. {letify-2.2.0 → letify-2.4.0}/letify/declare/function.py +0 -0
  69. {letify-2.2.0 → letify-2.4.0}/letify/declare/instance.py +0 -0
  70. {letify-2.2.0 → letify-2.4.0}/letify/errors.py +0 -0
  71. {letify-2.2.0 → letify-2.4.0}/letify/install.py +0 -0
  72. {letify-2.2.0 → letify-2.4.0}/letify/paths.py +0 -0
  73. {letify-2.2.0 → letify-2.4.0}/letify/protocol/__init__.py +0 -0
  74. {letify-2.2.0 → letify-2.4.0}/letify/protocol/codec.py +0 -0
  75. {letify-2.2.0 → letify-2.4.0}/letify/protocol/driver.py +0 -0
  76. {letify-2.2.0 → letify-2.4.0}/letify/protocol/handle.py +0 -0
  77. {letify-2.2.0 → letify-2.4.0}/letify/protocol/wire.py +0 -0
  78. {letify-2.2.0 → letify-2.4.0}/letify/protocol/worker.py +0 -0
  79. {letify-2.2.0 → letify-2.4.0}/letify/providers/__init__.py +0 -0
  80. {letify-2.2.0 → letify-2.4.0}/letify/providers/colab_files.py +0 -0
  81. {letify-2.2.0 → letify-2.4.0}/letify/providers/colab_keepalive.py +0 -0
  82. {letify-2.2.0 → letify-2.4.0}/letify/providers/elice.py +0 -0
  83. {letify-2.2.0 → letify-2.4.0}/letify/providers/kaggle_adapter.py +0 -0
  84. {letify-2.2.0 → letify-2.4.0}/letify/providers/local.py +0 -0
  85. {letify-2.2.0 → letify-2.4.0}/letify/providers/modal.py +0 -0
  86. {letify-2.2.0 → letify-2.4.0}/letify/providers/modal_adapter.py +0 -0
  87. {letify-2.2.0 → letify-2.4.0}/letify/providers/naming.py +0 -0
  88. {letify-2.2.0 → letify-2.4.0}/letify/providers/shell.py +0 -0
  89. {letify-2.2.0 → letify-2.4.0}/letify/providers/tunnel.py +0 -0
  90. {letify-2.2.0 → letify-2.4.0}/letify/providers/usage.py +0 -0
  91. {letify-2.2.0 → letify-2.4.0}/letify/remoting/__init__.py +0 -0
  92. {letify-2.2.0 → letify-2.4.0}/letify/remoting/capability.py +0 -0
  93. {letify-2.2.0 → letify-2.4.0}/letify/remoting/device/__init__.py +0 -0
  94. {letify-2.2.0 → letify-2.4.0}/letify/remoting/device/client.py +0 -0
  95. {letify-2.2.0 → letify-2.4.0}/letify/remoting/device/cuda.py +0 -0
  96. {letify-2.2.0 → letify-2.4.0}/letify/remoting/device/executor.py +0 -0
  97. {letify-2.2.0 → letify-2.4.0}/letify/remoting/device/frames.py +0 -0
  98. {letify-2.2.0 → letify-2.4.0}/letify/remoting/device/guard.py +0 -0
  99. {letify-2.2.0 → letify-2.4.0}/letify/remoting/device/tensor.py +0 -0
  100. {letify-2.2.0 → letify-2.4.0}/letify/remoting/device/trace.py +0 -0
  101. {letify-2.2.0 → letify-2.4.0}/letify/remoting/device/value.py +0 -0
  102. {letify-2.2.0 → letify-2.4.0}/letify/remoting/loader.py +0 -0
  103. {letify-2.2.0 → letify-2.4.0}/letify/remoting/probe.py +0 -0
  104. {letify-2.2.0 → letify-2.4.0}/letify/render.py +0 -0
  105. {letify-2.2.0 → letify-2.4.0}/letify/runtime/__init__.py +0 -0
  106. {letify-2.2.0 → letify-2.4.0}/letify/runtime/channel.py +0 -0
  107. {letify-2.2.0 → letify-2.4.0}/letify/runtime/lease.py +0 -0
  108. {letify-2.2.0 → letify-2.4.0}/letify/runtime/pool.py +0 -0
  109. {letify-2.2.0 → letify-2.4.0}/letify/runtime/telemetry.py +0 -0
  110. {letify-2.2.0 → letify-2.4.0}/letify/store/__init__.py +0 -0
  111. {letify-2.2.0 → letify-2.4.0}/letify/store/backends/__init__.py +0 -0
  112. {letify-2.2.0 → letify-2.4.0}/letify/store/backends/filesystem.py +0 -0
  113. {letify-2.2.0 → letify-2.4.0}/letify/store/backends/google_auth.py +0 -0
  114. {letify-2.2.0 → letify-2.4.0}/letify/store/backends/layout.py +0 -0
  115. {letify-2.2.0 → letify-2.4.0}/letify/store/backends/objects.py +0 -0
  116. {letify-2.2.0 → letify-2.4.0}/letify/store/cas.py +0 -0
  117. {letify-2.2.0 → letify-2.4.0}/letify/store/pathdata.py +0 -0
  118. {letify-2.2.0 → letify-2.4.0}/letify/store/sendorder.py +0 -0
  119. {letify-2.2.0 → letify-2.4.0}/letify/store/volume.py +0 -0
  120. {letify-2.2.0 → letify-2.4.0}/letify/stubs.py +0 -0
  121. {letify-2.2.0 → letify-2.4.0}/letify/tools.py +0 -0
  122. {letify-2.2.0 → letify-2.4.0}/letify/transport/__init__.py +0 -0
  123. {letify-2.2.0 → letify-2.4.0}/letify/transport/agent.py +0 -0
  124. {letify-2.2.0 → letify-2.4.0}/letify/transport/announce.py +0 -0
  125. {letify-2.2.0 → letify-2.4.0}/letify/transport/link.py +0 -0
  126. {letify-2.2.0 → letify-2.4.0}/letify/transport/nat.py +0 -0
  127. {letify-2.2.0 → letify-2.4.0}/letify/transport/portmap.py +0 -0
  128. {letify-2.2.0 → letify-2.4.0}/letify/transport/probe.py +0 -0
  129. {letify-2.2.0 → letify-2.4.0}/letify/transport/quic.py +0 -0
  130. {letify-2.2.0 → letify-2.4.0}/letify/transport/rendezvous.py +0 -0
  131. {letify-2.2.0 → letify-2.4.0}/letify/transport/setup.py +0 -0
  132. {letify-2.2.0 → letify-2.4.0}/letify/transport/sshopts.py +0 -0
  133. {letify-2.2.0 → letify-2.4.0}/letify/transport/strategies.py +0 -0
  134. {letify-2.2.0 → letify-2.4.0}/letify-core/.gitignore +0 -0
  135. {letify-2.2.0 → letify-2.4.0}/letify-core/build.py +0 -0
  136. {letify-2.2.0 → letify-2.4.0}/letify-core/letify-agent/Cargo.toml +0 -0
  137. {letify-2.2.0 → letify-2.4.0}/letify-core/letify-agent/src/driver.rs +0 -0
  138. {letify-2.2.0 → letify-2.4.0}/letify-core/letify-agent/src/main.rs +0 -0
  139. {letify-2.2.0 → letify-2.4.0}/letify-core/letify-driver/Cargo.toml +0 -0
  140. {letify-2.2.0 → letify-2.4.0}/letify-core/letify-driver/src/client.rs +0 -0
  141. {letify-2.2.0 → letify-2.4.0}/letify-core/letify-driver/src/exports.rs +0 -0
  142. {letify-2.2.0 → letify-2.4.0}/letify-core/letify-driver/src/image.rs +0 -0
  143. {letify-2.2.0 → letify-2.4.0}/letify-core/letify-driver/src/lib.rs +0 -0
  144. {letify-2.2.0 → letify-2.4.0}/letify-core/letify-driver/src/status.rs +0 -0
  145. {letify-2.2.0 → letify-2.4.0}/letify-core/letify-driver/src/table.rs +0 -0
  146. {letify-2.2.0 → letify-2.4.0}/letify-core/letify-quic/Cargo.toml +0 -0
  147. {letify-2.2.0 → letify-2.4.0}/letify-core/letify-quic/src/main.rs +0 -0
  148. {letify-2.2.0 → letify-2.4.0}/letify-core/letify-wire/Cargo.toml +0 -0
  149. {letify-2.2.0 → letify-2.4.0}/letify-core/letify-wire/src/lib.rs +0 -0
  150. {letify-2.2.0 → letify-2.4.0}/letify-core/letify-wire/tests/throughput.rs +0 -0
  151. {letify-2.2.0 → letify-2.4.0}/letify-core/rust-toolchain.toml +0 -0
  152. {letify-2.2.0 → letify-2.4.0}/letify-ext/.vscodeignore +0 -0
  153. {letify-2.2.0 → letify-2.4.0}/letify-ext/LICENSE +0 -0
  154. {letify-2.2.0 → letify-2.4.0}/letify-ext/README.md +0 -0
  155. {letify-2.2.0 → letify-2.4.0}/letify-ext/media/icon.svg +0 -0
  156. {letify-2.2.0 → letify-2.4.0}/letify-ext/src/cli.ts +0 -0
  157. {letify-2.2.0 → letify-2.4.0}/letify-ext/src/extension.ts +0 -0
  158. {letify-2.2.0 → letify-2.4.0}/letify-ext/src/history.ts +0 -0
  159. {letify-2.2.0 → letify-2.4.0}/letify-ext/src/model.ts +0 -0
  160. {letify-2.2.0 → letify-2.4.0}/letify-ext/src/poll.ts +0 -0
  161. {letify-2.2.0 → letify-2.4.0}/letify-ext/src/render.ts +0 -0
  162. {letify-2.2.0 → letify-2.4.0}/letify-ext/test/cli.test.ts +0 -0
  163. {letify-2.2.0 → letify-2.4.0}/letify-ext/test/manifest.test.ts +0 -0
  164. {letify-2.2.0 → letify-2.4.0}/letify-ext/test/model.test.ts +0 -0
  165. {letify-2.2.0 → letify-2.4.0}/letify-ext/test/regressions.test.ts +0 -0
  166. {letify-2.2.0 → letify-2.4.0}/letify-ext/tsconfig.json +0 -0
  167. {letify-2.2.0 → letify-2.4.0}/letify_providers.py +0 -0
  168. {letify-2.2.0 → letify-2.4.0}/scripts/sync-main.sh +0 -0
  169. {letify-2.2.0 → letify-2.4.0}/scripts/version.py +0 -0
  170. {letify-2.2.0 → letify-2.4.0}/tests/fake_kaggle_adapter.py +0 -0
  171. {letify-2.2.0 → letify-2.4.0}/tests/fake_modal_adapter.py +0 -0
  172. {letify-2.2.0 → letify-2.4.0}/tests/test_cli.py +0 -0
  173. {letify-2.2.0 → letify-2.4.0}/tests/test_cli_output.py +0 -0
  174. {letify-2.2.0 → letify-2.4.0}/tests/test_colab_keepalive.py +0 -0
  175. {letify-2.2.0 → letify-2.4.0}/tests/test_config.py +0 -0
  176. {letify-2.2.0 → letify-2.4.0}/tests/test_core.py +0 -0
  177. {letify-2.2.0 → letify-2.4.0}/tests/test_data.py +0 -0
  178. {letify-2.2.0 → letify-2.4.0}/tests/test_data_streaming.py +0 -0
  179. {letify-2.2.0 → letify-2.4.0}/tests/test_declare.py +0 -0
  180. {letify-2.2.0 → letify-2.4.0}/tests/test_device.py +0 -0
  181. {letify-2.2.0 → letify-2.4.0}/tests/test_editor_sessions.py +0 -0
  182. {letify-2.2.0 → letify-2.4.0}/tests/test_frames.py +0 -0
  183. {letify-2.2.0 → letify-2.4.0}/tests/test_install.py +0 -0
  184. {letify-2.2.0 → letify-2.4.0}/tests/test_kaggle_cookie.py +0 -0
  185. {letify-2.2.0 → letify-2.4.0}/tests/test_link_log.py +0 -0
  186. {letify-2.2.0 → letify-2.4.0}/tests/test_notebook.py +0 -0
  187. {letify-2.2.0 → letify-2.4.0}/tests/test_parallel_placement.py +0 -0
  188. {letify-2.2.0 → letify-2.4.0}/tests/test_paths.py +0 -0
  189. {letify-2.2.0 → letify-2.4.0}/tests/test_portmap.py +0 -0
  190. {letify-2.2.0 → letify-2.4.0}/tests/test_protocol.py +0 -0
  191. {letify-2.2.0 → letify-2.4.0}/tests/test_providers.py +0 -0
  192. {letify-2.2.0 → letify-2.4.0}/tests/test_quic.py +0 -0
  193. {letify-2.2.0 → letify-2.4.0}/tests/test_remoting.py +0 -0
  194. {letify-2.2.0 → letify-2.4.0}/tests/test_render.py +0 -0
  195. {letify-2.2.0 → letify-2.4.0}/tests/test_ship.py +0 -0
  196. {letify-2.2.0 → letify-2.4.0}/tests/test_store.py +0 -0
  197. {letify-2.2.0 → letify-2.4.0}/tests/test_strategies.py +0 -0
  198. {letify-2.2.0 → letify-2.4.0}/tests/test_stubs.py +0 -0
  199. {letify-2.2.0 → letify-2.4.0}/tests/test_tunnel_setup.py +0 -0
  200. {letify-2.2.0 → letify-2.4.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.4.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.4.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,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)
@@ -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/"
@@ -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
- default_workspace = "/kaggle/working/letify"
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.2.0"
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.2.0"
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.2.0"
167
+ version = "2.4.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.4.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.4.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.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.2.0",
5
+ "version": "2.4.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.4.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):