letify 1.2.0__tar.gz → 1.2.2__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 (192) hide show
  1. {letify-1.2.0 → letify-1.2.2}/PKG-INFO +4 -1
  2. {letify-1.2.0 → letify-1.2.2}/README.md +3 -0
  3. {letify-1.2.0 → letify-1.2.2}/docs/guide/04-environments-and-data.md +1 -1
  4. {letify-1.2.0 → letify-1.2.2}/docs/guide/07-troubleshooting.md +1 -1
  5. {letify-1.2.0 → letify-1.2.2}/docs/guide/ko/04-/355/231/230/352/262/275/352/263/274-/353/215/260/354/235/264/355/204/260.md +1 -1
  6. {letify-1.2.0 → letify-1.2.2}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md +1 -1
  7. {letify-1.2.0 → letify-1.2.2}/docs/locales/README_ko.md +3 -0
  8. {letify-1.2.0 → letify-1.2.2}/letify/__init__.py +1 -1
  9. {letify-1.2.0 → letify-1.2.2}/letify/config/login.py +39 -7
  10. {letify-1.2.0 → letify-1.2.2}/letify/paths.py +1 -1
  11. {letify-1.2.0 → letify-1.2.2}/letify/protocol/driver.py +42 -13
  12. {letify-1.2.0 → letify-1.2.2}/letify/protocol/worker.py +20 -21
  13. {letify-1.2.0 → letify-1.2.2}/letify/providers/base.py +3 -3
  14. {letify-1.2.0 → letify-1.2.2}/letify/providers/colab.py +40 -20
  15. {letify-1.2.0 → letify-1.2.2}/letify/providers/colab_keepalive.py +24 -6
  16. {letify-1.2.0 → letify-1.2.2}/letify/providers/kaggle.py +28 -14
  17. {letify-1.2.0 → letify-1.2.2}/letify/runtime/channel.py +4 -2
  18. {letify-1.2.0 → letify-1.2.2}/letify/runtime/session.py +20 -7
  19. {letify-1.2.0 → letify-1.2.2}/letify/store/pathdata.py +113 -4
  20. {letify-1.2.0 → letify-1.2.2}/letify/tools.py +2 -25
  21. {letify-1.2.0 → letify-1.2.2}/letify/transport/pipeline.py +18 -11
  22. {letify-1.2.0 → letify-1.2.2}/letify/transport/rendezvous.py +16 -3
  23. {letify-1.2.0 → letify-1.2.2}/letify-core/Cargo.lock +3 -3
  24. {letify-1.2.0 → letify-1.2.2}/letify-core/Cargo.toml +1 -1
  25. {letify-1.2.0 → letify-1.2.2}/letify-ext/package-lock.json +2 -2
  26. {letify-1.2.0 → letify-1.2.2}/letify-ext/package.json +1 -1
  27. {letify-1.2.0 → letify-1.2.2}/pyproject.toml +1 -1
  28. {letify-1.2.0 → letify-1.2.2}/tests/test_colab_keepalive.py +22 -0
  29. {letify-1.2.0 → letify-1.2.2}/tests/test_kaggle.py +178 -3
  30. {letify-1.2.0 → letify-1.2.2}/tests/test_paths.py +1 -1
  31. {letify-1.2.0 → letify-1.2.2}/tests/test_protocol.py +16 -26
  32. {letify-1.2.0 → letify-1.2.2}/tests/test_providers.py +153 -6
  33. letify-1.2.2/tests/test_ship.py +239 -0
  34. {letify-1.2.0 → letify-1.2.2}/tests/test_transport.py +69 -2
  35. {letify-1.2.0 → letify-1.2.2}/.github/workflows/ci.yml +0 -0
  36. {letify-1.2.0 → letify-1.2.2}/.github/workflows/docs.yml +0 -0
  37. {letify-1.2.0 → letify-1.2.2}/.github/workflows/publish.yml +0 -0
  38. {letify-1.2.0 → letify-1.2.2}/.gitignore +0 -0
  39. {letify-1.2.0 → letify-1.2.2}/.researchtree +0 -0
  40. {letify-1.2.0 → letify-1.2.2}/LICENSE +0 -0
  41. {letify-1.2.0 → letify-1.2.2}/docs/guide/01-getting-started.md +0 -0
  42. {letify-1.2.0 → letify-1.2.2}/docs/guide/02-providers.md +0 -0
  43. {letify-1.2.0 → letify-1.2.2}/docs/guide/03-execution-modes.md +0 -0
  44. {letify-1.2.0 → letify-1.2.2}/docs/guide/05-concurrency.md +0 -0
  45. {letify-1.2.0 → letify-1.2.2}/docs/guide/06-cost.md +0 -0
  46. {letify-1.2.0 → letify-1.2.2}/docs/guide/08-releasing.md +0 -0
  47. {letify-1.2.0 → letify-1.2.2}/docs/guide/README.md +0 -0
  48. {letify-1.2.0 → letify-1.2.2}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  49. {letify-1.2.0 → letify-1.2.2}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  50. {letify-1.2.0 → letify-1.2.2}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  51. {letify-1.2.0 → letify-1.2.2}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  52. {letify-1.2.0 → letify-1.2.2}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  53. {letify-1.2.0 → letify-1.2.2}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  54. {letify-1.2.0 → letify-1.2.2}/docs/guide/ko/README.md +0 -0
  55. {letify-1.2.0 → letify-1.2.2}/docs/mkdocs.yml +0 -0
  56. {letify-1.2.0 → letify-1.2.2}/examples/README.md +0 -0
  57. {letify-1.2.0 → letify-1.2.2}/examples/nvfp4-lora/.letify.example +0 -0
  58. {letify-1.2.0 → letify-1.2.2}/examples/nvfp4-lora/00_smoke.py +0 -0
  59. {letify-1.2.0 → letify-1.2.2}/examples/nvfp4-lora/01_sweep.py +0 -0
  60. {letify-1.2.0 → letify-1.2.2}/examples/nvfp4-lora/02_resume.py +0 -0
  61. {letify-1.2.0 → letify-1.2.2}/examples/nvfp4-lora/03_watch.py +0 -0
  62. {letify-1.2.0 → letify-1.2.2}/examples/nvfp4-lora/README.md +0 -0
  63. {letify-1.2.0 → letify-1.2.2}/examples/nvfp4-lora/README_ko.md +0 -0
  64. {letify-1.2.0 → letify-1.2.2}/examples/nvfp4-lora/common.py +0 -0
  65. {letify-1.2.0 → letify-1.2.2}/examples/nvfp4-lora/recipe.py +0 -0
  66. {letify-1.2.0 → letify-1.2.2}/letify/cli.py +0 -0
  67. {letify-1.2.0 → letify-1.2.2}/letify/config/__init__.py +0 -0
  68. {letify-1.2.0 → letify-1.2.2}/letify/config/inventory.py +0 -0
  69. {letify-1.2.0 → letify-1.2.2}/letify/config/schema.py +0 -0
  70. {letify-1.2.0 → letify-1.2.2}/letify/config/secrets.py +0 -0
  71. {letify-1.2.0 → letify-1.2.2}/letify/config/writer.py +0 -0
  72. {letify-1.2.0 → letify-1.2.2}/letify/declare/__init__.py +0 -0
  73. {letify-1.2.0 → letify-1.2.2}/letify/declare/cache.py +0 -0
  74. {letify-1.2.0 → letify-1.2.2}/letify/declare/env.py +0 -0
  75. {letify-1.2.0 → letify-1.2.2}/letify/declare/function.py +0 -0
  76. {letify-1.2.0 → letify-1.2.2}/letify/declare/instance.py +0 -0
  77. {letify-1.2.0 → letify-1.2.2}/letify/errors.py +0 -0
  78. {letify-1.2.0 → letify-1.2.2}/letify/install.py +0 -0
  79. {letify-1.2.0 → letify-1.2.2}/letify/launcher.py +0 -0
  80. {letify-1.2.0 → letify-1.2.2}/letify/protocol/__init__.py +0 -0
  81. {letify-1.2.0 → letify-1.2.2}/letify/protocol/codec.py +0 -0
  82. {letify-1.2.0 → letify-1.2.2}/letify/protocol/handle.py +0 -0
  83. {letify-1.2.0 → letify-1.2.2}/letify/protocol/wire.py +0 -0
  84. {letify-1.2.0 → letify-1.2.2}/letify/providers/__init__.py +0 -0
  85. {letify-1.2.0 → letify-1.2.2}/letify/providers/colab_files.py +0 -0
  86. {letify-1.2.0 → letify-1.2.2}/letify/providers/elice.py +0 -0
  87. {letify-1.2.0 → letify-1.2.2}/letify/providers/kaggle_adapter.py +0 -0
  88. {letify-1.2.0 → letify-1.2.2}/letify/providers/local.py +0 -0
  89. {letify-1.2.0 → letify-1.2.2}/letify/providers/modal.py +0 -0
  90. {letify-1.2.0 → letify-1.2.2}/letify/providers/modal_adapter.py +0 -0
  91. {letify-1.2.0 → letify-1.2.2}/letify/providers/naming.py +0 -0
  92. {letify-1.2.0 → letify-1.2.2}/letify/providers/shell.py +0 -0
  93. {letify-1.2.0 → letify-1.2.2}/letify/providers/tunnel.py +0 -0
  94. {letify-1.2.0 → letify-1.2.2}/letify/providers/usage.py +0 -0
  95. {letify-1.2.0 → letify-1.2.2}/letify/remoting/__init__.py +0 -0
  96. {letify-1.2.0 → letify-1.2.2}/letify/remoting/capability.py +0 -0
  97. {letify-1.2.0 → letify-1.2.2}/letify/remoting/device/__init__.py +0 -0
  98. {letify-1.2.0 → letify-1.2.2}/letify/remoting/device/client.py +0 -0
  99. {letify-1.2.0 → letify-1.2.2}/letify/remoting/device/cuda.py +0 -0
  100. {letify-1.2.0 → letify-1.2.2}/letify/remoting/device/executor.py +0 -0
  101. {letify-1.2.0 → letify-1.2.2}/letify/remoting/device/frames.py +0 -0
  102. {letify-1.2.0 → letify-1.2.2}/letify/remoting/device/guard.py +0 -0
  103. {letify-1.2.0 → letify-1.2.2}/letify/remoting/device/tensor.py +0 -0
  104. {letify-1.2.0 → letify-1.2.2}/letify/remoting/device/trace.py +0 -0
  105. {letify-1.2.0 → letify-1.2.2}/letify/remoting/device/value.py +0 -0
  106. {letify-1.2.0 → letify-1.2.2}/letify/remoting/loader.py +0 -0
  107. {letify-1.2.0 → letify-1.2.2}/letify/remoting/probe.py +0 -0
  108. {letify-1.2.0 → letify-1.2.2}/letify/render.py +0 -0
  109. {letify-1.2.0 → letify-1.2.2}/letify/runtime/__init__.py +0 -0
  110. {letify-1.2.0 → letify-1.2.2}/letify/runtime/bootstrap.py +0 -0
  111. {letify-1.2.0 → letify-1.2.2}/letify/runtime/lease.py +0 -0
  112. {letify-1.2.0 → letify-1.2.2}/letify/runtime/pool.py +0 -0
  113. {letify-1.2.0 → letify-1.2.2}/letify/runtime/telemetry.py +0 -0
  114. {letify-1.2.0 → letify-1.2.2}/letify/store/__init__.py +0 -0
  115. {letify-1.2.0 → letify-1.2.2}/letify/store/backends/__init__.py +0 -0
  116. {letify-1.2.0 → letify-1.2.2}/letify/store/backends/filesystem.py +0 -0
  117. {letify-1.2.0 → letify-1.2.2}/letify/store/backends/google_auth.py +0 -0
  118. {letify-1.2.0 → letify-1.2.2}/letify/store/backends/layout.py +0 -0
  119. {letify-1.2.0 → letify-1.2.2}/letify/store/backends/objects.py +0 -0
  120. {letify-1.2.0 → letify-1.2.2}/letify/store/cas.py +0 -0
  121. {letify-1.2.0 → letify-1.2.2}/letify/store/sendorder.py +0 -0
  122. {letify-1.2.0 → letify-1.2.2}/letify/store/volume.py +0 -0
  123. {letify-1.2.0 → letify-1.2.2}/letify/stubs.py +0 -0
  124. {letify-1.2.0 → letify-1.2.2}/letify/transport/__init__.py +0 -0
  125. {letify-1.2.0 → letify-1.2.2}/letify/transport/agent.py +0 -0
  126. {letify-1.2.0 → letify-1.2.2}/letify/transport/announce.py +0 -0
  127. {letify-1.2.0 → letify-1.2.2}/letify/transport/link.py +0 -0
  128. {letify-1.2.0 → letify-1.2.2}/letify/transport/nat.py +0 -0
  129. {letify-1.2.0 → letify-1.2.2}/letify/transport/probe.py +0 -0
  130. {letify-1.2.0 → letify-1.2.2}/letify/transport/setup.py +0 -0
  131. {letify-1.2.0 → letify-1.2.2}/letify/transport/sshopts.py +0 -0
  132. {letify-1.2.0 → letify-1.2.2}/letify/transport/strategies.py +0 -0
  133. {letify-1.2.0 → letify-1.2.2}/letify-core/.gitignore +0 -0
  134. {letify-1.2.0 → letify-1.2.2}/letify-core/build.py +0 -0
  135. {letify-1.2.0 → letify-1.2.2}/letify-core/letify-agent/Cargo.toml +0 -0
  136. {letify-1.2.0 → letify-1.2.2}/letify-core/letify-agent/src/driver.rs +0 -0
  137. {letify-1.2.0 → letify-1.2.2}/letify-core/letify-agent/src/main.rs +0 -0
  138. {letify-1.2.0 → letify-1.2.2}/letify-core/letify-driver/Cargo.toml +0 -0
  139. {letify-1.2.0 → letify-1.2.2}/letify-core/letify-driver/src/client.rs +0 -0
  140. {letify-1.2.0 → letify-1.2.2}/letify-core/letify-driver/src/exports.rs +0 -0
  141. {letify-1.2.0 → letify-1.2.2}/letify-core/letify-driver/src/image.rs +0 -0
  142. {letify-1.2.0 → letify-1.2.2}/letify-core/letify-driver/src/lib.rs +0 -0
  143. {letify-1.2.0 → letify-1.2.2}/letify-core/letify-driver/src/status.rs +0 -0
  144. {letify-1.2.0 → letify-1.2.2}/letify-core/letify-driver/src/table.rs +0 -0
  145. {letify-1.2.0 → letify-1.2.2}/letify-core/letify-wire/Cargo.toml +0 -0
  146. {letify-1.2.0 → letify-1.2.2}/letify-core/letify-wire/src/lib.rs +0 -0
  147. {letify-1.2.0 → letify-1.2.2}/letify-core/letify-wire/tests/throughput.rs +0 -0
  148. {letify-1.2.0 → letify-1.2.2}/letify-core/rust-toolchain.toml +0 -0
  149. {letify-1.2.0 → letify-1.2.2}/letify-ext/.vscodeignore +0 -0
  150. {letify-1.2.0 → letify-1.2.2}/letify-ext/LICENSE +0 -0
  151. {letify-1.2.0 → letify-1.2.2}/letify-ext/README.md +0 -0
  152. {letify-1.2.0 → letify-1.2.2}/letify-ext/media/icon.svg +0 -0
  153. {letify-1.2.0 → letify-1.2.2}/letify-ext/src/cli.ts +0 -0
  154. {letify-1.2.0 → letify-1.2.2}/letify-ext/src/extension.ts +0 -0
  155. {letify-1.2.0 → letify-1.2.2}/letify-ext/src/history.ts +0 -0
  156. {letify-1.2.0 → letify-1.2.2}/letify-ext/src/model.ts +0 -0
  157. {letify-1.2.0 → letify-1.2.2}/letify-ext/src/poll.ts +0 -0
  158. {letify-1.2.0 → letify-1.2.2}/letify-ext/src/render.ts +0 -0
  159. {letify-1.2.0 → letify-1.2.2}/letify-ext/test/cli.test.ts +0 -0
  160. {letify-1.2.0 → letify-1.2.2}/letify-ext/test/manifest.test.ts +0 -0
  161. {letify-1.2.0 → letify-1.2.2}/letify-ext/test/model.test.ts +0 -0
  162. {letify-1.2.0 → letify-1.2.2}/letify-ext/test/regressions.test.ts +0 -0
  163. {letify-1.2.0 → letify-1.2.2}/letify-ext/tsconfig.json +0 -0
  164. {letify-1.2.0 → letify-1.2.2}/letify_providers.py +0 -0
  165. {letify-1.2.0 → letify-1.2.2}/scripts/sync-main.sh +0 -0
  166. {letify-1.2.0 → letify-1.2.2}/scripts/version.py +0 -0
  167. {letify-1.2.0 → letify-1.2.2}/tests/conftest.py +0 -0
  168. {letify-1.2.0 → letify-1.2.2}/tests/fake_kaggle_adapter.py +0 -0
  169. {letify-1.2.0 → letify-1.2.2}/tests/fake_modal_adapter.py +0 -0
  170. {letify-1.2.0 → letify-1.2.2}/tests/test_cli.py +0 -0
  171. {letify-1.2.0 → letify-1.2.2}/tests/test_cli_output.py +0 -0
  172. {letify-1.2.0 → letify-1.2.2}/tests/test_config.py +0 -0
  173. {letify-1.2.0 → letify-1.2.2}/tests/test_core.py +0 -0
  174. {letify-1.2.0 → letify-1.2.2}/tests/test_data.py +0 -0
  175. {letify-1.2.0 → letify-1.2.2}/tests/test_data_streaming.py +0 -0
  176. {letify-1.2.0 → letify-1.2.2}/tests/test_declare.py +0 -0
  177. {letify-1.2.0 → letify-1.2.2}/tests/test_device.py +0 -0
  178. {letify-1.2.0 → letify-1.2.2}/tests/test_editor_sessions.py +0 -0
  179. {letify-1.2.0 → letify-1.2.2}/tests/test_elice.py +0 -0
  180. {letify-1.2.0 → letify-1.2.2}/tests/test_frames.py +0 -0
  181. {letify-1.2.0 → letify-1.2.2}/tests/test_install.py +0 -0
  182. {letify-1.2.0 → letify-1.2.2}/tests/test_kaggle_cookie.py +0 -0
  183. {letify-1.2.0 → letify-1.2.2}/tests/test_link_log.py +0 -0
  184. {letify-1.2.0 → letify-1.2.2}/tests/test_notebook.py +0 -0
  185. {letify-1.2.0 → letify-1.2.2}/tests/test_remoting.py +0 -0
  186. {letify-1.2.0 → letify-1.2.2}/tests/test_render.py +0 -0
  187. {letify-1.2.0 → letify-1.2.2}/tests/test_runtime.py +0 -0
  188. {letify-1.2.0 → letify-1.2.2}/tests/test_store.py +0 -0
  189. {letify-1.2.0 → letify-1.2.2}/tests/test_strategies.py +0 -0
  190. {letify-1.2.0 → letify-1.2.2}/tests/test_stubs.py +0 -0
  191. {letify-1.2.0 → letify-1.2.2}/tests/test_tunnel_setup.py +0 -0
  192. {letify-1.2.0 → letify-1.2.2}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 1.2.0
3
+ Version: 1.2.2
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
@@ -192,6 +192,7 @@ Each command writes two files. The account goes to `~/.letify/config.toml`, whic
192
192
  [colab_pro_plus]
193
193
  kind = "colab"
194
194
  account = "you@example.com"
195
+ min_mib_per_s = 0.05
195
196
 
196
197
  [lab_a100]
197
198
  kind = "shell"
@@ -201,6 +202,8 @@ key = "~/.ssh/id_ed25519"
201
202
  persistent = true
202
203
  ```
203
204
 
205
+ The connection pipeline defaults to `10 MiB/s` in each direction and `300 ms` RTT. Colab's measured sustained best is `8.8 MiB/s` up and `10.7 MiB/s` down, so it fails the default floor. The account example explicitly permits a slow link with `min_mib_per_s = 0.05`. Set `min_mib_per_s` and `max_rtt_ms` for your measurements. When all measured links fail the floor, the connection is refused even if an unprobed fallback connected.
206
+
204
207
  The project's `.letify/config.toml` gets only the alias, which is what makes the account usable in this project:
205
208
 
206
209
  ```toml
@@ -167,6 +167,7 @@ Each command writes two files. The account goes to `~/.letify/config.toml`, whic
167
167
  [colab_pro_plus]
168
168
  kind = "colab"
169
169
  account = "you@example.com"
170
+ min_mib_per_s = 0.05
170
171
 
171
172
  [lab_a100]
172
173
  kind = "shell"
@@ -176,6 +177,8 @@ key = "~/.ssh/id_ed25519"
176
177
  persistent = true
177
178
  ```
178
179
 
180
+ The connection pipeline defaults to `10 MiB/s` in each direction and `300 ms` RTT. Colab's measured sustained best is `8.8 MiB/s` up and `10.7 MiB/s` down, so it fails the default floor. The account example explicitly permits a slow link with `min_mib_per_s = 0.05`. Set `min_mib_per_s` and `max_rtt_ms` for your measurements. When all measured links fail the floor, the connection is refused even if an unprobed fallback connected.
181
+
179
182
  The project's `.letify/config.toml` gets only the alias, which is what makes the account usable in this project:
180
183
 
181
184
  ```toml
@@ -43,7 +43,7 @@ Packages named in the lock file are installed in the runtime and referenced by n
43
43
  letify infers this from the lock file. Override the inference only when you need to:
44
44
 
45
45
  ```python
46
- env = env.ship("mypkg") # send this module by value
46
+ env = env.ship("mypkg") # ship this module to the runtime
47
47
  ```
48
48
 
49
49
  This is why editing code and rerunning is cheap. Code is a few hundred kilobytes; the environment and the data are the expensive parts, and they are cached.
@@ -264,7 +264,7 @@ kind = "tunnel"
264
264
  mtu = 1280
265
265
  ```
266
266
 
267
- letify holds every connection strategy, Tailcat's hole punch included, to a floor: a probe below 5 MiB/s or above 300 ms round trip fails the connection instead of completing it, because a slow link is a failure, not a fallback. See [docs/SPEC.md, Link floor](../SPEC.md#link-floor). If the connection now fails with "below the floor", that is the diagnosis: the path is too slow to use, usually because Tailcat's own direct UDP punch did not succeed and it fell back to a relay. No relay path is offered as an alternative, so fix the underlying reachability instead: open the UDP port the punch needs, or set `address` to a direct SSH path if one exists on the same network. See [docs/NETWORK.md](../NETWORK.md).
267
+ letify holds every connection strategy, Tailcat's hole punch included, to a floor: a probe below 10 MiB/s or above 300 ms round trip fails the connection instead of completing it, because a slow link is a failure, not a fallback. See [docs/SPEC.md, Link floor](../SPEC.md#link-floor). If the connection now fails with "below the floor", that is the diagnosis: the path is too slow to use, usually because Tailcat's own direct UDP punch did not succeed and it fell back to a relay. No relay path is offered as an alternative, so fix the underlying reachability instead: open the UDP port the punch needs, or set `address` to a direct SSH path if one exists on the same network. See [docs/NETWORK.md](../NETWORK.md).
268
268
 
269
269
  ### Gathered calls are not running in parallel
270
270
 
@@ -43,7 +43,7 @@ lock 파일에 있는 패키지는 런타임에 설치되고 이름으로 참조
43
43
  letify가 lock 파일을 보고 판단합니다. 필요할 때만 덮어쓰세요.
44
44
 
45
45
  ```python
46
- env = env.ship("mypkg") # 이 모듈을 내용으로 보냅니다
46
+ env = env.ship("mypkg") # 이 모듈을 런타임으로 전송합니다
47
47
  ```
48
48
 
49
49
  그래서 코드를 고치고 다시 돌리는 것이 쌉니다. 코드는 수백 KB이고, 비싼 것은 환경과 데이터이며 그건 캐시됩니다.
@@ -264,7 +264,7 @@ kind = "tunnel"
264
264
  mtu = 1280
265
265
  ```
266
266
 
267
- letify는 Tailcat 홀펀칭을 포함한 모든 연결 전략을 바닥값에 묶어 둡니다: 측정값이 5 MiB/s 미만이거나 왕복 300 ms를 넘으면 연결을 완료시키는 대신 실패시킵니다. 느린 링크는 폴백이 아니라 실패이기 때문입니다. [docs/SPEC.md의 Link floor](../../SPEC.md#link-floor)를 보세요. 이제 "below the floor"로 연결이 실패한다면 그것이 진단입니다: 경로가 너무 느려 쓸 수 없고, 보통 Tailcat의 직접 UDP 홀펀칭이 성공하지 못해 중계로 떨어졌기 때문입니다. 대안으로 중계 경로를 제공하지 않으므로, 도달성 자체를 고치세요: 홀펀칭에 필요한 UDP 포트를 열거나, 같은 망에 직접 SSH 경로가 있다면 `address`를 설정하세요. [docs/NETWORK.md](../../NETWORK.md)를 보세요.
267
+ letify는 Tailcat 홀펀칭을 포함한 모든 연결 전략을 바닥값에 묶어 둡니다: 측정값이 10 MiB/s 미만이거나 왕복 300 ms를 넘으면 연결을 완료시키는 대신 실패시킵니다. 느린 링크는 폴백이 아니라 실패이기 때문입니다. [docs/SPEC.md의 Link floor](../../SPEC.md#link-floor)를 보세요. 이제 "below the floor"로 연결이 실패한다면 그것이 진단입니다: 경로가 너무 느려 쓸 수 없고, 보통 Tailcat의 직접 UDP 홀펀칭이 성공하지 못해 중계로 떨어졌기 때문입니다. 대안으로 중계 경로를 제공하지 않으므로, 도달성 자체를 고치세요: 홀펀칭에 필요한 UDP 포트를 열거나, 같은 망에 직접 SSH 경로가 있다면 `address`를 설정하세요. [docs/NETWORK.md](../../NETWORK.md)를 보세요.
268
268
 
269
269
  ### 모은 호출이 병렬로 돌지 않습니다
270
270
 
@@ -161,6 +161,7 @@ letify login shell lab_a100
161
161
  [colab_pro_plus]
162
162
  kind = "colab"
163
163
  account = "you@example.com"
164
+ min_mib_per_s = 0.05
164
165
 
165
166
  [lab_a100]
166
167
  kind = "shell"
@@ -170,6 +171,8 @@ key = "~/.ssh/id_ed25519"
170
171
  persistent = true
171
172
  ```
172
173
 
174
+ 연결 파이프라인의 기본 기준은 양방향 `10 MiB/s`, RTT `300 ms`입니다. Colab의 측정된 지속 전송 최고값은 올리기 `8.8 MiB/s`, 내리기 `10.7 MiB/s`라서 기본 기준을 통과하지 못합니다. 위 계정의 `min_mib_per_s = 0.05`는 느린 연결을 명시적으로 허용하는 예시입니다. 실제 측정에 맞춰 `min_mib_per_s`와 `max_rtt_ms`를 설정하세요. 측정된 링크가 모두 기준 미달이면 측정하지 않은 fallback으로 바꾸지 않고 연결을 거부합니다.
175
+
173
176
  프로젝트의 `.letify/config.toml`에는 alias만 들어갑니다. 이 줄이 있어야 그 계정을 이 프로젝트에서 쓸 수 있습니다.
174
177
 
175
178
  ```toml
@@ -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__ = "1.2.0"
65
+ __version__ = "1.2.2"
66
66
 
67
67
 
68
68
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -756,12 +756,25 @@ def colab_account(answers: Answers) -> dict[str, Any]:
756
756
  KAGGLE_COOKIE = "cookie"
757
757
  KAGGLE_COOKIE_PROMPT = "Kaggle cookie (from a logged-in kaggle.com tab): "
758
758
 
759
+ #: What each credential prompt adds on a renewal, where a blank answer keeps the stored
760
+ #: one. Spec "Kaggle account".
761
+ KAGGLE_KEEP_SUFFIX = "[blank keeps the stored one] "
759
762
 
760
- def read_kaggle_cookie(answers: Answers, given: str | None) -> str:
761
- """Return the cookie string, from the value given, a file it names, or a prompt."""
763
+
764
+ def read_kaggle_cookie(answers: Answers, given: str | None, stored: str | None = None) -> str:
765
+ """Return the cookie string, from the value given, a file it names, or a prompt.
766
+
767
+ On a renewal the prompt says a blank answer keeps ``stored``, so the cookie, which is a
768
+ thirty day session, can be replaced without repasting the token. Spec "Kaggle account".
769
+ """
762
770
  text = (given or "").strip()
763
771
  if not text and answers.interactive:
764
- text = read_password(KAGGLE_COOKIE_PROMPT).strip()
772
+ prompt = KAGGLE_COOKIE_PROMPT
773
+ if stored:
774
+ prompt = f"{KAGGLE_COOKIE_PROMPT}{KAGGLE_KEEP_SUFFIX}"
775
+ text = read_password(prompt).strip()
776
+ if not text and stored:
777
+ return stored
765
778
  if not text:
766
779
  raise LoginError(
767
780
  f"{answers.alias} needs the Kaggle cookie of a logged-in kaggle.com tab. "
@@ -794,9 +807,15 @@ def read_kaggle_token(
794
807
  username = read_line(KAGGLE_OWNER_PROMPT).strip()
795
808
  from ..providers.kaggle import read_api_token
796
809
 
797
- key = given_key if given_key is not None else (read_api_token(answers.alias) or "")
798
- if not key and answers.interactive:
799
- key = read_password(KAGGLE_TOKEN_PROMPT)
810
+ stored = read_api_token(answers.alias)
811
+ key = given_key or ""
812
+ if not key.strip() and answers.interactive:
813
+ prompt = KAGGLE_TOKEN_PROMPT
814
+ if stored:
815
+ prompt = f"{KAGGLE_TOKEN_PROMPT}{KAGGLE_KEEP_SUFFIX}"
816
+ key = read_password(prompt)
817
+ if not key.strip() and stored:
818
+ key = stored
800
819
  if not username or not key:
801
820
  raise LoginError(
802
821
  f"{answers.alias} needs the Kaggle API token and notebook owner: pass "
@@ -827,7 +846,11 @@ def kaggle_account(answers: Answers) -> dict[str, Any]:
827
846
 
828
847
  username, key = read_kaggle_token(answers, answers.get("username"), answers.get("key"))
829
848
  given = answers.get(KAGGLE_COOKIE) or answers.token
830
- cookie = read_kaggle_cookie(answers, given if isinstance(given, str) else None)
849
+ cookie = read_kaggle_cookie(
850
+ answers,
851
+ given if isinstance(given, str) else None,
852
+ stored=stored_secret(answers.alias, KAGGLE_COOKIE),
853
+ )
831
854
  try:
832
855
  kg.require_cookie_shape(cookie)
833
856
  expiry = kg.cookie_expiry(cookie)
@@ -954,6 +977,15 @@ def store_secret(alias: str, name: str, secret: str) -> None:
954
977
  secrets.write_secret(alias, name, secret)
955
978
 
956
979
 
980
+ def stored_secret(alias: str, name: str) -> str | None:
981
+ """The credential already in the account directory, or None when there is none to keep."""
982
+ try:
983
+ value = (secrets.account_directory(alias) / name).read_text(encoding="utf-8").strip()
984
+ except (OSError, UnicodeError):
985
+ return None
986
+ return value or None
987
+
988
+
957
989
  def forget_secret(alias: str) -> bool:
958
990
  """Delete everything this machine holds for an account. Returns whether anything was there."""
959
991
  return secrets.forget_account(alias)
@@ -37,7 +37,7 @@ ALLOWED_ACCOUNT_CREDENTIAL_FILES = frozenset(
37
37
  "eci.yaml",
38
38
  "cookie",
39
39
  "access_token",
40
- "kaggle.json",
40
+ "username",
41
41
  "notebook_id",
42
42
  "known_hosts",
43
43
  "link.json",
@@ -13,12 +13,14 @@ is no blob table, so every large argument travels again on every call.
13
13
  from __future__ import annotations
14
14
 
15
15
  import base64
16
+ from collections.abc import Sequence
17
+ from pathlib import Path
16
18
  from typing import Any
17
19
 
18
20
  from .codec import BEGIN, END, PROTOCOL_VERSION, dumps_call
19
21
 
20
22
  _TEMPLATE = """
21
- import base64, pickle, sys, traceback
23
+ import base64, os, pickle, sys, traceback
22
24
  _PAYLOAD = "{payload}"
23
25
  _BEGIN, _END = "{begin}", "{end}"
24
26
  _VERSION = {version}
@@ -27,15 +29,22 @@ _NO_LETIFY = (
27
29
  "to letify (for example through letify.session_cache), cannot be loaded. Add it to "
28
30
  "the project with 'uv add letify' so uv.lock carries it into the runtime."
29
31
  )
30
- _SHIP_IS_NOT_INSTALL = (
31
- "{{name!r}} is not importable in this runtime while the call's body was running. "
32
- "Env.ship() sends a module by value: an object from it that the function captures "
33
- "as a global is carried inside the call's payload, but the module itself is never "
34
- "installed in the runtime, so 'import {{name}}' inside the function body has nothing "
35
- "to find. Reference the name as a global the function closes over instead of "
36
- "importing it inside the body, or install the package in the runtime's environment "
37
- "(name it in uv.lock) so it can be imported there by reference."
38
- )
32
+ _MODULES = {modules_code}
33
+ _WORKSPACE = {workspace_root!r}
34
+ if _MODULES:
35
+ _modules_dir = os.path.join(os.path.expanduser(_WORKSPACE), "modules")
36
+ os.makedirs(_modules_dir, exist_ok=True)
37
+ for _rel, _b64 in _MODULES.items():
38
+ _target = os.path.join(_modules_dir, _rel)
39
+ os.makedirs(os.path.dirname(_target), exist_ok=True)
40
+ with open(_target, "wb") as _f:
41
+ _f.write(base64.b64decode(_b64))
42
+ if _modules_dir not in sys.path:
43
+ sys.path.insert(0, _modules_dir)
44
+ _existing = os.environ.get("PYTHONPATH", "")
45
+ _parts = [p for p in _existing.split(os.pathsep) if p]
46
+ if _modules_dir not in _parts:
47
+ os.environ["PYTHONPATH"] = os.pathsep.join([_modules_dir, *_parts])
39
48
 
40
49
  def _emit(obj):
41
50
  try:
@@ -84,8 +93,6 @@ else:
84
93
  except BaseException as exc:
85
94
  if isinstance(exc, ModuleNotFoundError) and (exc.name or "").split(".")[0] == "letify":
86
95
  _error = _NO_LETIFY
87
- elif isinstance(exc, ModuleNotFoundError):
88
- _error = _SHIP_IS_NOT_INSTALL.format(name=exc.name)
89
96
  else:
90
97
  _error = "{{}}: {{}}".format(type(exc).__name__, exc)
91
98
  _emit({{
@@ -98,14 +105,36 @@ else:
98
105
  """
99
106
 
100
107
 
101
- def build(fn: Any, args: tuple, kwargs: dict) -> str:
108
+ def build(
109
+ fn: Any,
110
+ args: tuple,
111
+ kwargs: dict,
112
+ *,
113
+ modules: Sequence[str] = (),
114
+ workspace_root: str = "~/.letify-runtime",
115
+ ) -> str:
102
116
  """Return the script that runs one call and prints its outcome."""
103
117
  payload = base64.b64encode(dumps_call(fn, args, kwargs)).decode()
118
+ modules_dict: dict[str, str] = {}
119
+ if modules:
120
+ from ..store.pathdata import collect_module
121
+
122
+ modules_root = f"{workspace_root.rstrip('/')}/modules"
123
+ for mod_name in modules:
124
+ placed = collect_module(mod_name, modules_root)
125
+ prefix = mod_name.replace(".", "/")
126
+ for rel, _digest, _size, local in placed.entries:
127
+ content = Path(local).read_bytes()
128
+ target_rel = f"{prefix}/{rel}" if placed.directory else prefix + Path(local).suffix
129
+ modules_dict[target_rel] = base64.b64encode(content).decode("ascii")
130
+
104
131
  return _TEMPLATE.format(
105
132
  payload=payload,
106
133
  begin=BEGIN,
107
134
  end=END,
108
135
  version=PROTOCOL_VERSION,
136
+ modules_code=repr(modules_dict),
137
+ workspace_root=workspace_root,
109
138
  )
110
139
 
111
140
 
@@ -195,16 +195,6 @@ _NO_LETIFY = (
195
195
  "the project with 'uv add letify' so uv.lock carries it into the runtime."
196
196
  )
197
197
 
198
- _SHIP_IS_NOT_INSTALL = (
199
- "{name!r} is not importable in this runtime while the call's body was running. "
200
- "Env.ship() sends a module by value: an object from it that the function captures "
201
- "as a global is carried inside the call's payload, but the module itself is never "
202
- "installed in the runtime, so 'import {name}' inside the function body has nothing "
203
- "to find. Reference the name as a global the function closes over instead of "
204
- "importing it inside the body, or install the package in the runtime's environment "
205
- "(name it in uv.lock) so it can be imported there by reference."
206
- )
207
-
208
198
 
209
199
  def _ship_main_to_children(cloudpickle):
210
200
  """Let a child process the body spawns rebuild what the caller's __main__ defined.
@@ -266,6 +256,15 @@ def _reply(stream, outcome):
266
256
 
267
257
  def _op_call(request):
268
258
  data = request.pop("data", None)
259
+ modules_dir = request.pop("modules_dir", None)
260
+ if modules_dir:
261
+ modules_dir = os.path.expanduser(modules_dir)
262
+ if modules_dir not in sys.path:
263
+ sys.path.insert(0, modules_dir)
264
+ existing = os.environ.get("PYTHONPATH", "")
265
+ parts = [p for p in existing.split(os.pathsep) if p]
266
+ if modules_dir not in parts:
267
+ os.environ["PYTHONPATH"] = os.pathsep.join([modules_dir, *parts])
269
268
  if data is None:
270
269
  return _call(request)
271
270
  state = _data_register(data)
@@ -872,10 +871,18 @@ def _data_place_now(state, path, entry):
872
871
  # turns it into a private copy. Spec "Materializing and the rewritten path".
873
872
  linked = False
874
873
  try:
875
- os.link(source, path)
876
- linked = True
874
+ if os.path.exists(path) and os.stat(path).st_ino == os.stat(source).st_ino:
875
+ linked = True
877
876
  except OSError:
878
877
  pass
878
+ if not linked:
879
+ try:
880
+ if os.path.exists(path):
881
+ os.remove(path)
882
+ os.link(source, path)
883
+ linked = True
884
+ except OSError:
885
+ pass
879
886
  if not linked:
880
887
  # Copied to a name of its own and renamed into place, so a reader never stats a
881
888
  # file that is half written. Spec "What the body sees before a file arrives".
@@ -1533,15 +1540,7 @@ def _call(request):
1533
1540
  # refers to it when loaded, so the same explanation applies.
1534
1541
  if (exc.name or "").split(".")[0] == "letify":
1535
1542
  raise ModuleNotFoundError(_NO_LETIFY, name=exc.name) from exc
1536
- # A package Env.ship() sends by value never lands on this runtime's
1537
- # sys.path: cloudpickle only puts a captured global in the payload, it
1538
- # does not install anything. An import statement inside the body still
1539
- # runs against this interpreter's own path and fails here, even though
1540
- # the same name reached the call fine as a global. Say so, because the
1541
- # bare ModuleNotFoundError reads as a missing dependency rather than as
1542
- # this shape of call.
1543
- explained = _SHIP_IS_NOT_INSTALL.format(name=exc.name)
1544
- raise ModuleNotFoundError(explained, name=exc.name) from exc
1543
+ raise
1545
1544
  return {"ok": True, "value": value}
1546
1545
 
1547
1546
 
@@ -99,8 +99,8 @@ class Provider(abc.ABC):
99
99
  """What this account has, read from the entry's device table.
100
100
 
101
101
  This is the only thing that bounds how much runs at once. An entry that declares
102
- nothing falls back to whatever the provider discovers, one of each, because a
103
- provider that can be asked should not have to be told.
102
+ nothing falls back to the device counts of the shapes the provider discovers,
103
+ because a provider that can be asked should not have to be told.
104
104
  """
105
105
  if self._inventory is None:
106
106
  declared = read_table(self.config.options)
@@ -108,7 +108,7 @@ class Provider(abc.ABC):
108
108
  # Keyed by what an instance calls itself, so a discovered shape and its
109
109
  # inventory entry are the same name. A CPU shape reports "cpu".
110
110
  declared = {
111
- instance.accelerator: Devices(instance.accelerator)
111
+ instance.accelerator: Devices(instance.accelerator, count=instance.devices)
112
112
  for instance in self.discover().values()
113
113
  }
114
114
  self._inventory = declared
@@ -278,10 +278,27 @@ class Colab(Shell):
278
278
  return env
279
279
 
280
280
  def _exec(self, session: str, source: str, timeout: float | None) -> str:
281
- from .colab_keepalive import account_lock
282
-
283
- with account_lock(account_directory(self.alias) / ".config" / "colab-cli"):
281
+ try:
284
282
  return self._cli("exec", "-s", session, stdin=source, timeout=timeout)
283
+ except subprocess.TimeoutExpired as exc:
284
+ stderr = exc.stderr or ""
285
+ if isinstance(stderr, bytes):
286
+ stderr = stderr.decode(errors="replace")
287
+ raise RuntimeFailure(
288
+ f"{session}: colab exec timed out after {timeout} s; "
289
+ "the notebook kernel may be busy or not answering",
290
+ command=" ".join([*self._colab(), "exec", "-s", session]),
291
+ stderr=stderr,
292
+ ) from exc
293
+ except RuntimeFailure as exc:
294
+ if "Timeout waiting for reply" not in exc.stderr:
295
+ raise
296
+ raise RuntimeFailure(
297
+ f"{session}: colab exec did not receive a reply; "
298
+ "the notebook kernel may be busy or not answering",
299
+ command=exc.command,
300
+ stderr=exc.stderr,
301
+ ) from exc
285
302
 
286
303
  def sessions(self) -> list[str]:
287
304
  """Names of the sessions this account currently holds."""
@@ -309,8 +326,27 @@ class Colab(Shell):
309
326
  from ..transport.rendezvous import ColabRendezvous
310
327
 
311
328
  name = runtime.name
329
+
330
+ def prepare_tailcat() -> str | None:
331
+ from .. import install
332
+
333
+ if not (
334
+ install.find("tailcat", link_cache=False) or self.config.option("tailcat_binary")
335
+ ):
336
+ return None
337
+ output = self._exec(name, install.remote_tailcat_source(), 180)
338
+ for line in output.splitlines():
339
+ if line.startswith("LETIFY-TAILCAT "):
340
+ return json.loads(line[len("LETIFY-TAILCAT ") :])
341
+ raise RuntimeFailure(
342
+ "tailcat installation gave no verified binary path",
343
+ stderr=output.strip(),
344
+ )
345
+
312
346
  return ColabRendezvous(
313
- lambda source, timeout: self._exec(name, source, timeout), self._public_key()
347
+ lambda source, timeout: self._exec(name, source, timeout),
348
+ self._public_key(),
349
+ prepare_tailcat=prepare_tailcat,
314
350
  )
315
351
 
316
352
  def fallback(self, runtime: Runtime | None = None) -> Callable[[], Link] | None:
@@ -330,20 +366,6 @@ class Colab(Shell):
330
366
  def target(self, runtime: Runtime | None = None) -> Target:
331
367
  target = super().target(runtime)
332
368
  target.user = self.user or "root"
333
- if runtime is not None and target.rendezvous.unavailable() is None:
334
- from .. import install
335
-
336
- if install.find("tailcat", link_cache=False) or self.config.option("tailcat_binary"):
337
- output = self._exec(runtime.name, install.remote_tailcat_source(), 180)
338
- for line in output.splitlines():
339
- if line.startswith("LETIFY-TAILCAT "):
340
- target.rendezvous.binary = json.loads(line[len("LETIFY-TAILCAT ") :])
341
- break
342
- else:
343
- raise RuntimeFailure(
344
- "tailcat installation gave no verified binary path",
345
- stderr=output.strip(),
346
- )
347
369
  if runtime is not None:
348
370
  # Each runtime is a new VM with a new host key.
349
371
  target.host_key_alias = f"letify-{self.alias}-{runtime.name}"
@@ -507,5 +529,3 @@ __all__ = [
507
529
  "Colab",
508
530
  "ColabSessionReclaimed",
509
531
  ]
510
-
511
-
@@ -28,8 +28,8 @@ READY = "LETIFY-KEEP-ALIVE ready"
28
28
 
29
29
 
30
30
  @contextlib.contextmanager
31
- def account_lock(directory: Path) -> Iterator[None]:
32
- """Serialize full kernel commands sharing one account's CLI state."""
31
+ def account_lock(directory: Path, *, blocking: bool = True) -> Iterator[bool]:
32
+ """Serialize wakes sharing account CLI state, optionally without waiting."""
33
33
  directory.mkdir(parents=True, exist_ok=True)
34
34
  with (directory / "letify-wake.lock").open("a+b") as handle:
35
35
  if os.name == "nt": # pragma: no cover - Windows file locking
@@ -38,13 +38,23 @@ def account_lock(directory: Path) -> Iterator[None]:
38
38
  handle.write(b"\0")
39
39
  handle.flush()
40
40
  handle.seek(0)
41
- msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
41
+ try:
42
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK, 1)
43
+ except OSError:
44
+ if blocking:
45
+ raise
46
+ yield False
47
+ return
42
48
  else:
43
49
  import fcntl
44
50
 
45
- fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
51
+ try:
52
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB))
53
+ except BlockingIOError:
54
+ yield False
55
+ return
46
56
  try:
47
- yield
57
+ yield True
48
58
  finally:
49
59
  if os.name == "nt": # pragma: no cover - Windows file locking
50
60
  handle.seek(0)
@@ -60,7 +70,15 @@ def wake(config: dict[str, Any]) -> float:
60
70
  result = None
61
71
  exc = None
62
72
  try:
63
- with account_lock(Path(config["directory"])):
73
+ with account_lock(Path(config["directory"]), blocking=False) as acquired:
74
+ if not acquired:
75
+ entry = record(config, "wake_skipped", reason="account_busy")
76
+ print(
77
+ f"{entry['timestamp']} letify: wake {config['session']} skipped: account busy",
78
+ file=sys.stderr,
79
+ flush=True,
80
+ )
81
+ return SUCCESS_INTERVAL
64
82
  result = subprocess.run(
65
83
  command, input="pass\n", capture_output=True, text=True, timeout=WAKE_TIMEOUT
66
84
  )
@@ -65,12 +65,12 @@ if TYPE_CHECKING:
65
65
  from ..runtime.channel import Channel
66
66
  from ..runtime.session import Runtime
67
67
 
68
- #: Accelerators a Kaggle session can be started with, the memory of one card, and the name
69
- #: the internal API knows the card by. Both are ones the web app offers, so a session can be
70
- #: started on either. CPU is the empty compute, so it carries no accelerator name.
68
+ #: GPU session shapes, with memory per card, device count and the internal API name.
69
+ #: P100 remains available through the API although the web UI no longer lists it.
70
+ #: CPU is the empty compute, so it carries no accelerator name.
71
71
  GPUS = {
72
- "P100": {"vram_gb": 16, "accelerator": "NVIDIA_TESLA_P100"},
73
- "T4": {"vram_gb": 16, "accelerator": "NVIDIA_TESLA_T4"},
72
+ "P100": {"vram_gb": 16, "devices": 1, "accelerator": "NVIDIA_TESLA_P100"},
73
+ "T4": {"vram_gb": 16, "devices": 2, "accelerator": "NVIDIA_TESLA_T4"},
74
74
  }
75
75
  TPUS = ("TPU_V3_8",)
76
76
 
@@ -520,9 +520,9 @@ def cancel_run(cookie: str, run_id: int) -> None:
520
520
 
521
521
 
522
522
  def read_api_token(alias: str) -> str | None:
523
- """Read the account's verbatim API token, or None when the file is unreadable or empty."""
523
+ """Read the account's API token with file whitespace stripped, or None if unavailable."""
524
524
  try:
525
- token = (account_directory(alias) / "access_token").read_text(encoding="utf-8")
525
+ token = (account_directory(alias) / "access_token").read_text(encoding="utf-8").strip()
526
526
  except (OSError, UnicodeError):
527
527
  return None
528
528
  return token or None
@@ -544,6 +544,15 @@ def require_api_token(alias: str) -> str:
544
544
  return token
545
545
 
546
546
 
547
+ def read_notebook_owner(alias: str) -> str | None:
548
+ """Read the public notebook owner saved by login, or None if unavailable."""
549
+ try:
550
+ owner = (account_directory(alias) / "username").read_text(encoding="utf-8").strip()
551
+ except (OSError, UnicodeError):
552
+ return None
553
+ return owner or None
554
+
555
+
547
556
  def delete_notebook_via_cli(alias: str, slug: str | None) -> bool:
548
557
  """Delete the ephemeral notebook through the official CLI, the only way it is deleted.
549
558
 
@@ -558,11 +567,8 @@ def delete_notebook_via_cli(alias: str, slug: str | None) -> bool:
558
567
  token = read_api_token(alias)
559
568
  if token is None:
560
569
  return False
561
- try:
562
- username = (account_directory(alias) / "username").read_text(encoding="utf-8").strip()
563
- except (OSError, UnicodeError):
564
- return False
565
- if not username:
570
+ username = read_notebook_owner(alias)
571
+ if username is None:
566
572
  return False
567
573
  uv = tools.find_uv()
568
574
  if uv is None:
@@ -968,7 +974,10 @@ class Kaggle(Provider):
968
974
  """The fixed list of accelerators a Kaggle session offers. No call is made."""
969
975
  table: dict[str, Instance] = {"CPU": Instance(self, gpu=None)}
970
976
  table.update(
971
- {name: Instance(self, gpu=name, vram_gb=spec["vram_gb"]) for name, spec in GPUS.items()}
977
+ {
978
+ name: Instance(self, gpu=name, vram_gb=spec["vram_gb"], devices=spec["devices"])
979
+ for name, spec in GPUS.items()
980
+ }
972
981
  )
973
982
  table.update({name: Instance(self, tpu=name) for name in TPUS})
974
983
  return table
@@ -1036,10 +1045,15 @@ class Kaggle(Provider):
1036
1045
  The notebook is created fresh for this runtime alone, never reused from an earlier
1037
1046
  one: spec "Kaggle session token chain".
1038
1047
  """
1048
+ if read_notebook_owner(self.alias) is None:
1049
+ raise ConfigError(
1050
+ f"{self.alias} has no Kaggle notebook owner. Log in again with: "
1051
+ f"letify login kaggle {self.alias} --username <owner>"
1052
+ )
1039
1053
  cookie = self._require_live_cookie()
1040
1054
  instance = getattr(runtime, "instance", None)
1041
1055
  gpu = getattr(instance, "gpu", None)
1042
- accelerator = GPUS[gpu]["accelerator"] if gpu in GPUS else None
1056
+ accelerator = GPUS[gpu]["accelerator"] if gpu in GPUS else getattr(instance, "tpu", None)
1043
1057
  run, url, notebook, slug = live_session_url(self.alias, cookie, accelerator)
1044
1058
  session = Session(self.alias, url)
1045
1059
  session.wait_alive()
@@ -29,7 +29,7 @@ import subprocess
29
29
  import sys
30
30
  import threading
31
31
  import time
32
- from collections.abc import Callable, Iterator
32
+ from collections.abc import Callable, Iterator, Sequence
33
33
  from typing import IO, Any
34
34
 
35
35
  from .. import protocol
@@ -112,6 +112,7 @@ class Channel(abc.ABC):
112
112
  kwargs: dict,
113
113
  *,
114
114
  timeout: float | None = None,
115
+ modules: Sequence[str] = (),
115
116
  ) -> tuple[Any, str]:
116
117
  """Run one function call inside the runtime."""
117
118
  head, buffers = protocol.dumps_call_parts(fn, args, kwargs)
@@ -908,10 +909,11 @@ class OneShotChannel(Channel):
908
909
  kwargs: dict,
909
910
  *,
910
911
  timeout: float | None = None,
912
+ modules: Sequence[str] = (),
911
913
  ) -> tuple[Any, str]:
912
914
  from ..protocol import driver
913
915
 
914
- source = driver.build(fn, args, kwargs)
916
+ source = driver.build(fn, args, kwargs, modules=modules)
915
917
  stdout = self._run(source, timeout)
916
918
  logs, value = protocol.parse(stdout, runtime_key=self.name)
917
919
  return value, logs