letify 1.1.2__tar.gz → 1.2.1__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 (191) hide show
  1. {letify-1.1.2 → letify-1.2.1}/PKG-INFO +5 -2
  2. {letify-1.1.2 → letify-1.2.1}/README.md +4 -1
  3. {letify-1.1.2 → letify-1.2.1}/docs/guide/07-troubleshooting.md +1 -7
  4. {letify-1.1.2 → letify-1.2.1}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md +1 -7
  5. {letify-1.1.2 → letify-1.2.1}/docs/locales/README_ko.md +4 -1
  6. {letify-1.1.2 → letify-1.2.1}/letify/__init__.py +1 -1
  7. {letify-1.1.2 → letify-1.2.1}/letify/cli.py +157 -13
  8. {letify-1.1.2 → letify-1.2.1}/letify/config/login.py +57 -8
  9. {letify-1.1.2 → letify-1.2.1}/letify/declare/function.py +6 -2
  10. {letify-1.1.2 → letify-1.2.1}/letify/install.py +90 -0
  11. {letify-1.1.2 → letify-1.2.1}/letify/launcher.py +66 -1
  12. letify-1.2.1/letify/paths.py +168 -0
  13. {letify-1.1.2 → letify-1.2.1}/letify/protocol/codec.py +27 -0
  14. {letify-1.1.2 → letify-1.2.1}/letify/protocol/driver.py +16 -5
  15. {letify-1.1.2 → letify-1.2.1}/letify/protocol/worker.py +145 -1
  16. {letify-1.1.2 → letify-1.2.1}/letify/providers/base.py +1 -1
  17. {letify-1.1.2 → letify-1.2.1}/letify/providers/colab.py +151 -4
  18. {letify-1.1.2 → letify-1.2.1}/letify/providers/colab_files.py +5 -1
  19. letify-1.2.1/letify/providers/colab_keepalive.py +170 -0
  20. {letify-1.1.2 → letify-1.2.1}/letify/providers/kaggle.py +323 -98
  21. {letify-1.1.2 → letify-1.2.1}/letify/providers/local.py +5 -1
  22. {letify-1.1.2 → letify-1.2.1}/letify/providers/modal.py +5 -1
  23. {letify-1.1.2 → letify-1.2.1}/letify/providers/shell.py +21 -1
  24. {letify-1.1.2 → letify-1.2.1}/letify/runtime/bootstrap.py +82 -11
  25. {letify-1.1.2 → letify-1.2.1}/letify/runtime/lease.py +13 -2
  26. {letify-1.1.2 → letify-1.2.1}/letify/runtime/pool.py +29 -6
  27. {letify-1.1.2 → letify-1.2.1}/letify/runtime/session.py +77 -26
  28. {letify-1.1.2 → letify-1.2.1}/letify/store/backends/__init__.py +3 -2
  29. {letify-1.1.2 → letify-1.2.1}/letify/store/pathdata.py +113 -7
  30. {letify-1.1.2 → letify-1.2.1}/letify/store/volume.py +24 -8
  31. {letify-1.1.2 → letify-1.2.1}/letify/tools.py +132 -8
  32. {letify-1.1.2 → letify-1.2.1}/letify/transport/pipeline.py +143 -7
  33. {letify-1.1.2 → letify-1.2.1}/letify/transport/rendezvous.py +3 -0
  34. {letify-1.1.2 → letify-1.2.1}/letify/transport/sshopts.py +3 -2
  35. {letify-1.1.2 → letify-1.2.1}/letify/transport/strategies.py +5 -1
  36. {letify-1.1.2 → letify-1.2.1}/letify-core/Cargo.lock +3 -3
  37. {letify-1.1.2 → letify-1.2.1}/letify-core/Cargo.toml +1 -1
  38. {letify-1.1.2 → letify-1.2.1}/letify-ext/README.md +7 -5
  39. {letify-1.1.2 → letify-1.2.1}/letify-ext/package-lock.json +2 -2
  40. {letify-1.1.2 → letify-1.2.1}/letify-ext/package.json +5 -5
  41. {letify-1.1.2 → letify-1.2.1}/letify-ext/src/cli.ts +21 -1
  42. {letify-1.1.2 → letify-1.2.1}/letify-ext/src/extension.ts +26 -35
  43. {letify-1.1.2 → letify-1.2.1}/letify-ext/src/model.ts +48 -0
  44. letify-1.2.1/letify-ext/src/poll.ts +14 -0
  45. {letify-1.1.2 → letify-1.2.1}/letify-ext/src/render.ts +29 -19
  46. letify-1.2.1/letify-ext/test/cli.test.ts +37 -0
  47. letify-1.2.1/letify-ext/test/manifest.test.ts +16 -0
  48. letify-1.2.1/letify-ext/test/regressions.test.ts +89 -0
  49. {letify-1.1.2 → letify-1.2.1}/pyproject.toml +1 -1
  50. {letify-1.1.2 → letify-1.2.1}/tests/conftest.py +80 -1
  51. {letify-1.1.2 → letify-1.2.1}/tests/test_cli.py +37 -1
  52. letify-1.2.1/tests/test_colab_keepalive.py +101 -0
  53. {letify-1.1.2 → letify-1.2.1}/tests/test_data.py +15 -5
  54. {letify-1.1.2 → letify-1.2.1}/tests/test_data_streaming.py +160 -2
  55. letify-1.2.1/tests/test_editor_sessions.py +64 -0
  56. {letify-1.1.2 → letify-1.2.1}/tests/test_install.py +45 -0
  57. {letify-1.1.2 → letify-1.2.1}/tests/test_kaggle.py +440 -71
  58. {letify-1.1.2 → letify-1.2.1}/tests/test_link_log.py +7 -2
  59. letify-1.2.1/tests/test_notebook.py +55 -0
  60. letify-1.2.1/tests/test_paths.py +304 -0
  61. {letify-1.1.2 → letify-1.2.1}/tests/test_protocol.py +117 -0
  62. {letify-1.1.2 → letify-1.2.1}/tests/test_providers.py +168 -9
  63. {letify-1.1.2 → letify-1.2.1}/tests/test_runtime.py +259 -7
  64. {letify-1.1.2 → letify-1.2.1}/tests/test_store.py +30 -1
  65. {letify-1.1.2 → letify-1.2.1}/tests/test_transport.py +190 -6
  66. {letify-1.1.2 → letify-1.2.1}/.github/workflows/ci.yml +0 -0
  67. {letify-1.1.2 → letify-1.2.1}/.github/workflows/docs.yml +0 -0
  68. {letify-1.1.2 → letify-1.2.1}/.github/workflows/publish.yml +0 -0
  69. {letify-1.1.2 → letify-1.2.1}/.gitignore +0 -0
  70. {letify-1.1.2 → letify-1.2.1}/.researchtree +0 -0
  71. {letify-1.1.2 → letify-1.2.1}/LICENSE +0 -0
  72. {letify-1.1.2 → letify-1.2.1}/docs/guide/01-getting-started.md +0 -0
  73. {letify-1.1.2 → letify-1.2.1}/docs/guide/02-providers.md +0 -0
  74. {letify-1.1.2 → letify-1.2.1}/docs/guide/03-execution-modes.md +0 -0
  75. {letify-1.1.2 → letify-1.2.1}/docs/guide/04-environments-and-data.md +0 -0
  76. {letify-1.1.2 → letify-1.2.1}/docs/guide/05-concurrency.md +0 -0
  77. {letify-1.1.2 → letify-1.2.1}/docs/guide/06-cost.md +0 -0
  78. {letify-1.1.2 → letify-1.2.1}/docs/guide/08-releasing.md +0 -0
  79. {letify-1.1.2 → letify-1.2.1}/docs/guide/README.md +0 -0
  80. {letify-1.1.2 → letify-1.2.1}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  81. {letify-1.1.2 → letify-1.2.1}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  82. {letify-1.1.2 → letify-1.2.1}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  83. {letify-1.1.2 → letify-1.2.1}/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
  84. {letify-1.1.2 → letify-1.2.1}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  85. {letify-1.1.2 → letify-1.2.1}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  86. {letify-1.1.2 → letify-1.2.1}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  87. {letify-1.1.2 → letify-1.2.1}/docs/guide/ko/README.md +0 -0
  88. {letify-1.1.2 → letify-1.2.1}/docs/mkdocs.yml +0 -0
  89. {letify-1.1.2 → letify-1.2.1}/examples/README.md +0 -0
  90. {letify-1.1.2 → letify-1.2.1}/examples/nvfp4-lora/.letify.example +0 -0
  91. {letify-1.1.2 → letify-1.2.1}/examples/nvfp4-lora/00_smoke.py +0 -0
  92. {letify-1.1.2 → letify-1.2.1}/examples/nvfp4-lora/01_sweep.py +0 -0
  93. {letify-1.1.2 → letify-1.2.1}/examples/nvfp4-lora/02_resume.py +0 -0
  94. {letify-1.1.2 → letify-1.2.1}/examples/nvfp4-lora/03_watch.py +0 -0
  95. {letify-1.1.2 → letify-1.2.1}/examples/nvfp4-lora/README.md +0 -0
  96. {letify-1.1.2 → letify-1.2.1}/examples/nvfp4-lora/README_ko.md +0 -0
  97. {letify-1.1.2 → letify-1.2.1}/examples/nvfp4-lora/common.py +0 -0
  98. {letify-1.1.2 → letify-1.2.1}/examples/nvfp4-lora/recipe.py +0 -0
  99. {letify-1.1.2 → letify-1.2.1}/letify/config/__init__.py +0 -0
  100. {letify-1.1.2 → letify-1.2.1}/letify/config/inventory.py +0 -0
  101. {letify-1.1.2 → letify-1.2.1}/letify/config/schema.py +0 -0
  102. {letify-1.1.2 → letify-1.2.1}/letify/config/secrets.py +0 -0
  103. {letify-1.1.2 → letify-1.2.1}/letify/config/writer.py +0 -0
  104. {letify-1.1.2 → letify-1.2.1}/letify/declare/__init__.py +0 -0
  105. {letify-1.1.2 → letify-1.2.1}/letify/declare/cache.py +0 -0
  106. {letify-1.1.2 → letify-1.2.1}/letify/declare/env.py +0 -0
  107. {letify-1.1.2 → letify-1.2.1}/letify/declare/instance.py +0 -0
  108. {letify-1.1.2 → letify-1.2.1}/letify/errors.py +0 -0
  109. {letify-1.1.2 → letify-1.2.1}/letify/protocol/__init__.py +0 -0
  110. {letify-1.1.2 → letify-1.2.1}/letify/protocol/handle.py +0 -0
  111. {letify-1.1.2 → letify-1.2.1}/letify/protocol/wire.py +0 -0
  112. {letify-1.1.2 → letify-1.2.1}/letify/providers/__init__.py +0 -0
  113. {letify-1.1.2 → letify-1.2.1}/letify/providers/elice.py +0 -0
  114. {letify-1.1.2 → letify-1.2.1}/letify/providers/kaggle_adapter.py +0 -0
  115. {letify-1.1.2 → letify-1.2.1}/letify/providers/modal_adapter.py +0 -0
  116. {letify-1.1.2 → letify-1.2.1}/letify/providers/naming.py +0 -0
  117. {letify-1.1.2 → letify-1.2.1}/letify/providers/tunnel.py +0 -0
  118. {letify-1.1.2 → letify-1.2.1}/letify/providers/usage.py +0 -0
  119. {letify-1.1.2 → letify-1.2.1}/letify/remoting/__init__.py +0 -0
  120. {letify-1.1.2 → letify-1.2.1}/letify/remoting/capability.py +0 -0
  121. {letify-1.1.2 → letify-1.2.1}/letify/remoting/device/__init__.py +0 -0
  122. {letify-1.1.2 → letify-1.2.1}/letify/remoting/device/client.py +0 -0
  123. {letify-1.1.2 → letify-1.2.1}/letify/remoting/device/cuda.py +0 -0
  124. {letify-1.1.2 → letify-1.2.1}/letify/remoting/device/executor.py +0 -0
  125. {letify-1.1.2 → letify-1.2.1}/letify/remoting/device/frames.py +0 -0
  126. {letify-1.1.2 → letify-1.2.1}/letify/remoting/device/guard.py +0 -0
  127. {letify-1.1.2 → letify-1.2.1}/letify/remoting/device/tensor.py +0 -0
  128. {letify-1.1.2 → letify-1.2.1}/letify/remoting/device/trace.py +0 -0
  129. {letify-1.1.2 → letify-1.2.1}/letify/remoting/device/value.py +0 -0
  130. {letify-1.1.2 → letify-1.2.1}/letify/remoting/loader.py +0 -0
  131. {letify-1.1.2 → letify-1.2.1}/letify/remoting/probe.py +0 -0
  132. {letify-1.1.2 → letify-1.2.1}/letify/render.py +0 -0
  133. {letify-1.1.2 → letify-1.2.1}/letify/runtime/__init__.py +0 -0
  134. {letify-1.1.2 → letify-1.2.1}/letify/runtime/channel.py +0 -0
  135. {letify-1.1.2 → letify-1.2.1}/letify/runtime/telemetry.py +0 -0
  136. {letify-1.1.2 → letify-1.2.1}/letify/store/__init__.py +0 -0
  137. {letify-1.1.2 → letify-1.2.1}/letify/store/backends/filesystem.py +0 -0
  138. {letify-1.1.2 → letify-1.2.1}/letify/store/backends/google_auth.py +0 -0
  139. {letify-1.1.2 → letify-1.2.1}/letify/store/backends/layout.py +0 -0
  140. {letify-1.1.2 → letify-1.2.1}/letify/store/backends/objects.py +0 -0
  141. {letify-1.1.2 → letify-1.2.1}/letify/store/cas.py +0 -0
  142. {letify-1.1.2 → letify-1.2.1}/letify/store/sendorder.py +0 -0
  143. {letify-1.1.2 → letify-1.2.1}/letify/stubs.py +0 -0
  144. {letify-1.1.2 → letify-1.2.1}/letify/transport/__init__.py +0 -0
  145. {letify-1.1.2 → letify-1.2.1}/letify/transport/agent.py +0 -0
  146. {letify-1.1.2 → letify-1.2.1}/letify/transport/announce.py +0 -0
  147. {letify-1.1.2 → letify-1.2.1}/letify/transport/link.py +0 -0
  148. {letify-1.1.2 → letify-1.2.1}/letify/transport/nat.py +0 -0
  149. {letify-1.1.2 → letify-1.2.1}/letify/transport/probe.py +0 -0
  150. {letify-1.1.2 → letify-1.2.1}/letify/transport/setup.py +0 -0
  151. {letify-1.1.2 → letify-1.2.1}/letify-core/.gitignore +0 -0
  152. {letify-1.1.2 → letify-1.2.1}/letify-core/build.py +0 -0
  153. {letify-1.1.2 → letify-1.2.1}/letify-core/letify-agent/Cargo.toml +0 -0
  154. {letify-1.1.2 → letify-1.2.1}/letify-core/letify-agent/src/driver.rs +0 -0
  155. {letify-1.1.2 → letify-1.2.1}/letify-core/letify-agent/src/main.rs +0 -0
  156. {letify-1.1.2 → letify-1.2.1}/letify-core/letify-driver/Cargo.toml +0 -0
  157. {letify-1.1.2 → letify-1.2.1}/letify-core/letify-driver/src/client.rs +0 -0
  158. {letify-1.1.2 → letify-1.2.1}/letify-core/letify-driver/src/exports.rs +0 -0
  159. {letify-1.1.2 → letify-1.2.1}/letify-core/letify-driver/src/image.rs +0 -0
  160. {letify-1.1.2 → letify-1.2.1}/letify-core/letify-driver/src/lib.rs +0 -0
  161. {letify-1.1.2 → letify-1.2.1}/letify-core/letify-driver/src/status.rs +0 -0
  162. {letify-1.1.2 → letify-1.2.1}/letify-core/letify-driver/src/table.rs +0 -0
  163. {letify-1.1.2 → letify-1.2.1}/letify-core/letify-wire/Cargo.toml +0 -0
  164. {letify-1.1.2 → letify-1.2.1}/letify-core/letify-wire/src/lib.rs +0 -0
  165. {letify-1.1.2 → letify-1.2.1}/letify-core/letify-wire/tests/throughput.rs +0 -0
  166. {letify-1.1.2 → letify-1.2.1}/letify-core/rust-toolchain.toml +0 -0
  167. {letify-1.1.2 → letify-1.2.1}/letify-ext/.vscodeignore +0 -0
  168. {letify-1.1.2 → letify-1.2.1}/letify-ext/LICENSE +0 -0
  169. {letify-1.1.2 → letify-1.2.1}/letify-ext/media/icon.svg +0 -0
  170. {letify-1.1.2 → letify-1.2.1}/letify-ext/src/history.ts +0 -0
  171. {letify-1.1.2 → letify-1.2.1}/letify-ext/test/model.test.ts +0 -0
  172. {letify-1.1.2 → letify-1.2.1}/letify-ext/tsconfig.json +0 -0
  173. {letify-1.1.2 → letify-1.2.1}/letify_providers.py +0 -0
  174. {letify-1.1.2 → letify-1.2.1}/scripts/sync-main.sh +0 -0
  175. {letify-1.1.2 → letify-1.2.1}/scripts/version.py +0 -0
  176. {letify-1.1.2 → letify-1.2.1}/tests/fake_kaggle_adapter.py +0 -0
  177. {letify-1.1.2 → letify-1.2.1}/tests/fake_modal_adapter.py +0 -0
  178. {letify-1.1.2 → letify-1.2.1}/tests/test_cli_output.py +0 -0
  179. {letify-1.1.2 → letify-1.2.1}/tests/test_config.py +0 -0
  180. {letify-1.1.2 → letify-1.2.1}/tests/test_core.py +0 -0
  181. {letify-1.1.2 → letify-1.2.1}/tests/test_declare.py +0 -0
  182. {letify-1.1.2 → letify-1.2.1}/tests/test_device.py +0 -0
  183. {letify-1.1.2 → letify-1.2.1}/tests/test_elice.py +0 -0
  184. {letify-1.1.2 → letify-1.2.1}/tests/test_frames.py +0 -0
  185. {letify-1.1.2 → letify-1.2.1}/tests/test_kaggle_cookie.py +0 -0
  186. {letify-1.1.2 → letify-1.2.1}/tests/test_remoting.py +0 -0
  187. {letify-1.1.2 → letify-1.2.1}/tests/test_render.py +0 -0
  188. {letify-1.1.2 → letify-1.2.1}/tests/test_strategies.py +0 -0
  189. {letify-1.1.2 → letify-1.2.1}/tests/test_stubs.py +0 -0
  190. {letify-1.1.2 → letify-1.2.1}/tests/test_tunnel_setup.py +0 -0
  191. {letify-1.1.2 → letify-1.2.1}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 1.1.2
3
+ Version: 1.2.1
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
@@ -311,7 +314,7 @@ Provider
311
314
  | 🇰🇷 `Elice` | persistent | Korean GPU cloud, per-second billing |
312
315
  | 🏅 `Kaggle` | ephemeral | free weekly GPU hours, `host=letify.remote` only |
313
316
 
314
- **Kaggle.** Log in to kaggle.com in a browser and copy the `Cookie` request header of that tab (developer tools, Network, any kaggle.com request), then run `letify login kaggle kaggle_a`. The cookie is asked for without echo, and `--cookie` takes the value or a file holding it. letify checks its shape and its expiry before any network call, proves it with one request that returns your account, and keeps it in `~/.letify/accounts/kaggle_a/cookie` with mode 0600. The cookie expires 30 days after the browser login that made it, and nothing letify does extends that. `letify providers` and `letify usage kaggle_a` show `cookie expires in N days`, and a run on an expired cookie stops with an error telling you to log in again and repeat `letify login kaggle kaggle_a`. `letify usage kaggle_a` also prints the GPU hours left this week.
317
+ **Kaggle.** Log in to kaggle.com in a browser and copy the `Cookie` request header of that tab (developer tools, Network, any kaggle.com request), then run `letify login kaggle kaggle_a`. The cookie is asked for without echo, and `--cookie` takes the value or a file holding it. letify checks its shape and its expiry before any network call, proves it with one request that returns your account, and keeps it in `~/.letify/accounts/kaggle_a/cookie` with mode 0600. The cookie expires 30 days after the browser login that made it, and nothing letify does extends that. `letify providers` and `letify usage kaggle_a` show `cookie expires in N days`, and a run on an expired cookie stops with an error telling you to log in again and repeat `letify login kaggle kaggle_a`. `letify usage kaggle_a` also prints the GPU hours left this week. Login also asks for your Kaggle username (the notebook owner) and the API token exactly as kaggle.com shows it, including `KGAT_`. The token is stored in `access_token` beside the cookie with mode 0600, and every Kaggle CLI command receives it through `KAGGLE_API_TOKEN`.
315
318
 
316
319
  No step in the Kaggle editor is needed. letify starts the session itself, on a private notebook it creates in your account, and stops it when your program ends. The session runs one worker inside one kernel cell, and frames travel as base64 lines: about 9 MiB/s up and 1.5 MiB/s down, measured from Seoul. letify builds your declared environment there with `uv sync`, ships files, checks the worker's interpreter, and runs calls until Kaggle ends the session, after 20 minutes idle or 12 hours. It never sends a keep-alive.
317
320
 
@@ -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
@@ -286,7 +289,7 @@ Provider
286
289
  | 🇰🇷 `Elice` | persistent | Korean GPU cloud, per-second billing |
287
290
  | 🏅 `Kaggle` | ephemeral | free weekly GPU hours, `host=letify.remote` only |
288
291
 
289
- **Kaggle.** Log in to kaggle.com in a browser and copy the `Cookie` request header of that tab (developer tools, Network, any kaggle.com request), then run `letify login kaggle kaggle_a`. The cookie is asked for without echo, and `--cookie` takes the value or a file holding it. letify checks its shape and its expiry before any network call, proves it with one request that returns your account, and keeps it in `~/.letify/accounts/kaggle_a/cookie` with mode 0600. The cookie expires 30 days after the browser login that made it, and nothing letify does extends that. `letify providers` and `letify usage kaggle_a` show `cookie expires in N days`, and a run on an expired cookie stops with an error telling you to log in again and repeat `letify login kaggle kaggle_a`. `letify usage kaggle_a` also prints the GPU hours left this week.
292
+ **Kaggle.** Log in to kaggle.com in a browser and copy the `Cookie` request header of that tab (developer tools, Network, any kaggle.com request), then run `letify login kaggle kaggle_a`. The cookie is asked for without echo, and `--cookie` takes the value or a file holding it. letify checks its shape and its expiry before any network call, proves it with one request that returns your account, and keeps it in `~/.letify/accounts/kaggle_a/cookie` with mode 0600. The cookie expires 30 days after the browser login that made it, and nothing letify does extends that. `letify providers` and `letify usage kaggle_a` show `cookie expires in N days`, and a run on an expired cookie stops with an error telling you to log in again and repeat `letify login kaggle kaggle_a`. `letify usage kaggle_a` also prints the GPU hours left this week. Login also asks for your Kaggle username (the notebook owner) and the API token exactly as kaggle.com shows it, including `KGAT_`. The token is stored in `access_token` beside the cookie with mode 0600, and every Kaggle CLI command receives it through `KAGGLE_API_TOKEN`.
290
293
 
291
294
  No step in the Kaggle editor is needed. letify starts the session itself, on a private notebook it creates in your account, and stops it when your program ends. The session runs one worker inside one kernel cell, and frames travel as base64 lines: about 9 MiB/s up and 1.5 MiB/s down, measured from Seoul. letify builds your declared environment there with `uv sync`, ships files, checks the worker's interpreter, and runs calls until Kaggle ends the session, after 20 minutes idle or 12 hours. It never sends a keep-alive.
292
295
 
@@ -264,13 +264,7 @@ kind = "tunnel"
264
264
  mtu = 1280
265
265
  ```
266
266
 
267
- Then check whether the path is relayed, because a relayed Tailscale path has been measured as low as 2.2 Mbit/s across continents:
268
-
269
- ```python
270
- let.providers.home_box.diagnose()
271
- ```
272
-
273
- If it is relayed and UDP is blocked on your network, switch to `transport = "frp"` on TLS port 443. 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).
274
268
 
275
269
  ### Gathered calls are not running in parallel
276
270
 
@@ -264,13 +264,7 @@ kind = "tunnel"
264
264
  mtu = 1280
265
265
  ```
266
266
 
267
- 그다음 경로가 중계인지 확인하세요. 중계된 Tailscale 경로는 대륙 간에서 2.2Mbit/s까지 측정된 사례가 있습니다.
268
-
269
- ```python
270
- let.providers.home_box.diagnose()
271
- ```
272
-
273
- 중계이고 망에서 UDP가 막혀 있으면 TLS 443을 쓰는 `transport = "frp"`로 바꾸세요. [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)를 보세요.
274
268
 
275
269
  ### 모은 호출이 병렬로 돌지 않습니다
276
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
@@ -280,7 +283,7 @@ Provider
280
283
  | 🇰🇷 `Elice` | persistent | 한국 GPU 클라우드, 초 단위 과금 |
281
284
  | 🏅 `Kaggle` | ephemeral | 주간 무료 GPU 시간, `host=letify.remote` 전용 |
282
285
 
283
- **Kaggle.** 브라우저에서 kaggle.com에 로그인한 뒤 그 탭의 `Cookie` 요청 헤더를 복사하고(개발자 도구, Network, 아무 kaggle.com 요청), `letify login kaggle kaggle_a`를 실행합니다. 쿠키는 화면에 표시되지 않게 입력받고, `--cookie`로 값이나 값을 담은 파일을 넘겨도 됩니다. letify는 네트워크 호출 전에 쿠키의 형태와 만료를 검사하고, 계정을 돌려주는 요청 하나로 확인한 뒤 `~/.letify/accounts/kaggle_a/cookie`에 0600 권한으로 보관합니다. 쿠키는 그것을 만든 브라우저 로그인으로부터 30일 뒤에 만료되고, letify가 하는 어떤 일도 이를 연장하지 않습니다. `letify providers`와 `letify usage kaggle_a`는 `cookie expires in N days`를 보여 주고, 만료된 쿠키로 실행하면 다시 로그인해서 `letify login kaggle kaggle_a`를 반복하라는 오류로 멈춥니다. `letify usage kaggle_a`는 이번 주에 남은 GPU 시간도 보여 줍니다.
286
+ **Kaggle.** 브라우저에서 kaggle.com에 로그인한 뒤 그 탭의 `Cookie` 요청 헤더를 복사하고(개발자 도구, Network, 아무 kaggle.com 요청), `letify login kaggle kaggle_a`를 실행합니다. 쿠키는 화면에 표시되지 않게 입력받고, `--cookie`로 값이나 값을 담은 파일을 넘겨도 됩니다. letify는 네트워크 호출 전에 쿠키의 형태와 만료를 검사하고, 계정을 돌려주는 요청 하나로 확인한 뒤 `~/.letify/accounts/kaggle_a/cookie`에 0600 권한으로 보관합니다. 쿠키는 그것을 만든 브라우저 로그인으로부터 30일 뒤에 만료되고, letify가 하는 어떤 일도 이를 연장하지 않습니다. `letify providers`와 `letify usage kaggle_a`는 `cookie expires in N days`를 보여 주고, 만료된 쿠키로 실행하면 다시 로그인해서 `letify login kaggle kaggle_a`를 반복하라는 오류로 멈춥니다. `letify usage kaggle_a`는 이번 주에 남은 GPU 시간도 보여 줍니다. 로그인은 노트북 owner인 Kaggle 사용자 이름과 kaggle.com에 표시된 `KGAT_` 포함 API 토큰도 입력받습니다. 토큰은 쿠키 옆 `access_token`에 0600 권한으로 저장하며, 모든 Kaggle CLI 명령에 `KAGGLE_API_TOKEN` 환경 변수로 전달합니다.
284
287
 
285
288
  Kaggle 편집기에서 할 일은 없습니다. letify가 계정에 비공개 노트북을 만들어 세션을 직접 시작하고, 프로그램이 끝나면 세션을 끝냅니다. 세션은 커널 셀 하나 안에서 워커 하나를 돌리고, 프레임은 base64 줄로 오갑니다. 서울에서 측정해 올리기 약 9 MiB/s, 내리기 약 1.5 MiB/s입니다. letify는 그 세션에서 선언된 환경을 `uv sync`로 만들고, 파일을 주고받고, 워커의 인터프리터를 검사하고, Kaggle이 세션을 끝낼 때까지 호출을 실행합니다. 세션은 20분 동안 쓰지 않거나 12시간이 지나면 끝납니다. keep-alive는 보내지 않습니다.
286
289
 
@@ -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.1.2"
65
+ __version__ = "1.2.1"
66
66
 
67
67
 
68
68
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -9,7 +9,10 @@ from __future__ import annotations
9
9
 
10
10
  import argparse
11
11
  import json
12
+ import os
13
+ import shutil
12
14
  import sys
15
+ from pathlib import Path
13
16
 
14
17
  from . import __version__, render
15
18
  from .config import login
@@ -33,6 +36,10 @@ def build_parser() -> argparse.ArgumentParser:
33
36
  devices_parser.add_argument("--json", action="store_true", help="print the records unformatted")
34
37
  status_parser = sub.add_parser("status", help="show live runtimes and what they are costing")
35
38
  status_parser.add_argument("--json", action="store_true", help="print the records unformatted")
39
+ sessions_parser = sub.add_parser("sessions", help="list provider-owned sessions")
40
+ sessions_parser.add_argument(
41
+ "--json", action="store_true", help="print the records unformatted"
42
+ )
36
43
  sub.add_parser("stubs", help="write the provider types an editor completes")
37
44
 
38
45
  usage = sub.add_parser("usage", help="show what each account has left")
@@ -57,7 +64,9 @@ def build_parser() -> argparse.ArgumentParser:
57
64
  type=int,
58
65
  help="port forward SSH dials at --address when it differs from --port, for tunnel",
59
66
  )
60
- log_in.add_argument("--key", help="SSH private key path")
67
+ log_in.add_argument(
68
+ "--key", help="SSH private key path; with --username, the Kaggle API token instead"
69
+ )
61
70
  log_in.add_argument(
62
71
  "--auth",
63
72
  choices=login.AUTH_METHODS,
@@ -89,6 +98,13 @@ def build_parser() -> argparse.ArgumentParser:
89
98
  "--billing-endpoint", dest="billing_endpoint", help="Elice billing API base URL"
90
99
  )
91
100
  log_in.add_argument("--account", help="account email, for Colab")
101
+ log_in.add_argument(
102
+ "--username",
103
+ help=(
104
+ "Kaggle username, with --key the API token from Settings > API on kaggle.com; "
105
+ "required, for everything the official CLI covers that the cookie does not"
106
+ ),
107
+ )
92
108
  log_in.add_argument(
93
109
  "--workspace",
94
110
  metavar="PATH",
@@ -108,7 +124,7 @@ def build_parser() -> argparse.ArgumentParser:
108
124
  metavar="COOKIE",
109
125
  help=(
110
126
  "the browser cookie of a logged-in kaggle.com tab, or a file holding it, for "
111
- "kaggle; this is the whole Kaggle credential"
127
+ "kaggle; required alongside --username/--key, which the official CLI needs"
112
128
  ),
113
129
  )
114
130
  log_in.add_argument(
@@ -135,6 +151,11 @@ def build_parser() -> argparse.ArgumentParser:
135
151
  action="store_true",
136
152
  help="ask an already declared machine for its GPUs again and replace its devices table",
137
153
  )
154
+ log_in.add_argument(
155
+ "--replace",
156
+ action="store_true",
157
+ help="run the login again for an alias that already exists, to renew its credentials",
158
+ )
138
159
 
139
160
  log_out = sub.add_parser("logout", help="remove an account from this machine")
140
161
  log_out.add_argument("alias", help="provider alias to forget")
@@ -143,8 +164,13 @@ def build_parser() -> argparse.ArgumentParser:
143
164
  check.add_argument("alias", help="provider alias from the configuration")
144
165
 
145
166
  cache = sub.add_parser("cache", help="show the data caches, or clear a provider's")
146
- cache.add_argument("action", nargs="?", choices=("clear",), help="clear a runtime cache")
147
- cache.add_argument("alias", nargs="?", help="provider alias whose runtime cache to clear")
167
+ cache.add_argument(
168
+ "action",
169
+ nargs="?",
170
+ choices=("clear", "clean"),
171
+ help="clear a runtime cache or local cache",
172
+ )
173
+ cache.add_argument("alias", nargs="?", help="provider alias or local cache target to clear")
148
174
  cache.add_argument("--json", action="store_true", help="print the records unformatted")
149
175
 
150
176
  probe = sub.add_parser("probe", help="measure whether host='local' is worth using")
@@ -311,26 +337,114 @@ def _data_cache_request(let: Launcher, alias: str, clear: bool) -> dict:
311
337
  let.pool.release(runtime)
312
338
 
313
339
 
340
+ def _measure_tree(path: Path) -> tuple[int, int]:
341
+ """Return (file_count, total_bytes) for all files under path."""
342
+ if not path.exists():
343
+ return 0, 0
344
+ if path.is_file():
345
+ return 1, path.stat().st_size
346
+ files = 0
347
+ total_bytes = 0
348
+ for root, _, filenames in os.walk(path):
349
+ for name in filenames:
350
+ p = Path(root) / name
351
+ try:
352
+ stat_res = p.stat(follow_symlinks=False)
353
+ files += 1
354
+ total_bytes += stat_res.st_size
355
+ except OSError:
356
+ pass
357
+ return files, total_bytes
358
+
359
+
314
360
  def _cache(let: Launcher, args: argparse.Namespace) -> int:
315
361
  """Spec "The cache command"."""
362
+ from .paths import (
363
+ accounts_directory,
364
+ cache_directory,
365
+ local_runtime_directory,
366
+ local_tmp_directory,
367
+ ssh_control_directory,
368
+ tools_directory,
369
+ )
316
370
  from .store import pathdata
317
371
 
318
372
  mib = 1 << 20
319
- if args.action == "clear":
320
- if not args.alias:
321
- _fail("name the provider whose runtime cache to clear: letify cache clear <alias>")
373
+ local_trees: dict[str, tuple[Path, bool]] = {
374
+ "cache": (cache_directory(), True),
375
+ "tools": (tools_directory(), True),
376
+ "tmp": (local_tmp_directory(), True),
377
+ "ssh": (ssh_control_directory(), True),
378
+ "runtime": (local_runtime_directory(), True),
379
+ "accounts": (accounts_directory(), False),
380
+ }
381
+
382
+ if args.action in ("clear", "clean"):
383
+ target = args.alias
384
+ if not target:
385
+ _fail("name the provider or local tree to clear: letify cache clear <target>")
386
+ return 1
387
+ if target in ("accounts", "config", "config.toml"):
388
+ _fail(f"refused: {target} holds credentials or configuration and cannot be cleaned")
389
+ return 1
390
+
391
+ local_targets = ("cache", "tools", "tmp", "ssh", "runtime", "all")
392
+ if target in local_targets:
393
+ to_clean = ["cache", "tools", "tmp", "runtime"] if target == "all" else [target]
394
+ total_removed = 0
395
+ total_removed_bytes = 0
396
+ for item in to_clean:
397
+ dir_path, cleanable = local_trees[item]
398
+ if cleanable and dir_path.exists():
399
+ cnt, sz = _measure_tree(dir_path)
400
+ total_removed += cnt
401
+ total_removed_bytes += sz
402
+ for entry in list(dir_path.iterdir()):
403
+ if entry.is_dir():
404
+ shutil.rmtree(entry, ignore_errors=True)
405
+ else:
406
+ entry.unlink(missing_ok=True)
407
+ if args.json:
408
+ return _json(
409
+ {
410
+ "target": target,
411
+ "removed": total_removed,
412
+ "removed_bytes": total_removed_bytes,
413
+ }
414
+ )
415
+ print(
416
+ f"{target}: removed {total_removed} files "
417
+ f"{total_removed_bytes / mib:.1f} MiB"
418
+ )
419
+ return 0
420
+
421
+ # Provider target
422
+ try:
423
+ result = _data_cache_request(let, target, True)
424
+ except LetifyError as exc:
425
+ _fail(str(exc))
322
426
  return 1
323
- result = _data_cache_request(let, args.alias, True)
324
427
  if args.json:
325
- return _json({"alias": args.alias, **result})
428
+ return _json({"alias": target, **result})
326
429
  print(
327
- f"{args.alias}: removed {result['removed']} files "
430
+ f"{target}: removed {result['removed']} files "
328
431
  f"{result['removed_bytes'] / mib:.1f} MiB"
329
432
  )
330
433
  return 0
434
+
331
435
  digests = pathdata.DigestCache()
332
436
  pruned = digests.prune()
333
437
  digests.save()
438
+
439
+ local_stats: dict[str, dict] = {}
440
+ for name, (path, cleanable) in local_trees.items():
441
+ count, size_bytes = _measure_tree(path)
442
+ local_stats[name] = {
443
+ "files": count,
444
+ "bytes": size_bytes,
445
+ "cleanable": cleanable,
446
+ }
447
+
334
448
  records: list[dict] = []
335
449
  for alias in let.config.order:
336
450
  try:
@@ -351,10 +465,23 @@ def _cache(let: Launcher, args: argparse.Namespace) -> int:
351
465
  "budget": result["budget"],
352
466
  }
353
467
  )
354
- record = {"digests": {"entries": len(digests), "pruned": pruned}, "providers": records}
468
+ record = {
469
+ "digests": {"entries": len(digests), "pruned": pruned},
470
+ "local": local_stats,
471
+ "providers": records,
472
+ }
355
473
  if args.json:
356
474
  return _json(record)
475
+
357
476
  print(f"digest cache: {len(digests)} entries, {pruned} pruned")
477
+
478
+ local_rows = []
479
+ for name, (_path, cleanable) in local_trees.items():
480
+ st = local_stats[name]
481
+ clean_str = "yes" if cleanable else "no (credentials)"
482
+ local_rows.append([name, str(st["files"]), f"{st['bytes'] / mib:.1f} MiB", clean_str])
483
+ sys.stdout.write(render.table(["LOCAL", "FILES", "SIZE", "CLEANABLE"], local_rows, _out()))
484
+
358
485
  rows = []
359
486
  for row in records:
360
487
  if "unavailable" in row:
@@ -447,6 +574,7 @@ def _dispatch(args: argparse.Namespace) -> int:
447
574
  "organization": args.organization,
448
575
  "billing_endpoint": args.billing_endpoint,
449
576
  "account": args.account,
577
+ "username": args.username,
450
578
  "workspace": args.workspace,
451
579
  "profile": args.profile,
452
580
  "connect": args.connect,
@@ -457,6 +585,7 @@ def _dispatch(args: argparse.Namespace) -> int:
457
585
  token=args.token,
458
586
  interactive=args.interactive,
459
587
  install_key=args.install_key,
588
+ replace=args.replace,
460
589
  )
461
590
  try:
462
591
  fresh, home, project = login.log_in(answers, project=args.config)
@@ -464,9 +593,14 @@ def _dispatch(args: argparse.Namespace) -> int:
464
593
  _fail(str(exc))
465
594
  return 1
466
595
  if fresh:
467
- _say("ok", f"{alias} declared in {home}")
596
+ what = "renewed" if args.replace else "declared"
597
+ _say("ok", f"{alias} {what} in {home}")
468
598
  else:
469
- _say("warn", f"{alias} was already declared in {home}, so nothing was asked for")
599
+ _say(
600
+ "warn",
601
+ f"{alias} was already declared in {home}, so nothing was asked for. "
602
+ f"Pass --replace to renew its credentials",
603
+ )
470
604
  _say("ok", f"{alias} referenced in {project}, which is safe to commit")
471
605
  return 0
472
606
 
@@ -535,6 +669,16 @@ def _dispatch(args: argparse.Namespace) -> int:
535
669
  sys.stdout.write(render.table(["PROVIDER", "ACCELERATORS"], rows, _out()))
536
670
  return 0
537
671
 
672
+ if args.command == "sessions":
673
+ rows = let.sessions()
674
+ if args.json:
675
+ return _json(rows)
676
+ for row in rows:
677
+ names = ", ".join(row["sessions"])
678
+ detail = row.get("unavailable") or row.get("reason") or names or "no active sessions"
679
+ print(f"{row['alias']} {detail}")
680
+ return 0
681
+
538
682
  if args.command == "status":
539
683
  status = let.status()
540
684
  if args.json:
@@ -63,6 +63,9 @@ class Answers:
63
63
  token: str | None = None
64
64
  interactive: bool = True
65
65
  install_key: bool = True
66
+ #: Run the kind's whole flow again for an alias that already exists, so a credential
67
+ #: can be renewed. Spec "Logging in".
68
+ replace: bool = False
66
69
 
67
70
  def get(self, name: str) -> Any:
68
71
  return self.values.get(name)
@@ -738,6 +741,7 @@ def colab_account(answers: Answers) -> dict[str, Any]:
738
741
  [*tools.command(tools.COLAB, uv), "sessions"],
739
742
  env=tools.environment(answers.alias),
740
743
  )
744
+ tools.sync_tool_credentials(answers.alias)
741
745
  if result.returncode != 0:
742
746
  raise LoginError(f"the Colab sign in exited {result.returncode}, so nothing was written")
743
747
  # The rendezvous installs this key's public half on each runtime, which is what lets
@@ -748,7 +752,7 @@ def colab_account(answers: Answers) -> dict[str, Any]:
748
752
  return options
749
753
 
750
754
 
751
- #: The cookie file and the prompt for it. The cookie is the whole Kaggle credential.
755
+ #: The browser cookie file and its prompt.
752
756
  KAGGLE_COOKIE = "cookie"
753
757
  KAGGLE_COOKIE_PROMPT = "Kaggle cookie (from a logged-in kaggle.com tab): "
754
758
 
@@ -772,16 +776,56 @@ def read_kaggle_cookie(answers: Answers, given: str | None) -> str:
772
776
  return text
773
777
 
774
778
 
775
- def kaggle_account(answers: Answers) -> dict[str, Any]:
776
- """Store the browser session cookie and prove it names a live login.
779
+ #: The notebook owner and verbatim API token prompts.
780
+ KAGGLE_OWNER_PROMPT = "Kaggle username (notebook owner): "
781
+ KAGGLE_TOKEN_PROMPT = "Kaggle API token exactly as shown on kaggle.com, including KGAT_: "
782
+
783
+
784
+ def read_kaggle_token(
785
+ answers: Answers, given_username: str | None, given_key: str | None
786
+ ) -> tuple[str, str]:
787
+ """Return the notebook owner and verbatim token, refusing malformed token input.
777
788
 
778
- The cookie is the whole account: only the web session principal can mint the Jupyter
779
- proxy token, so an API key is useless here. The cookie is checked for shape and expiry
780
- before any network call, then proven with a read of the account. Nothing is written
781
- until the check passes.
789
+ The owner addresses notebook deletion; only the token authenticates the CLI. Validate
790
+ the prefix and a nonempty whitespace-free body without imposing an unproven length.
791
+ """
792
+ username = (given_username or "").strip()
793
+ if not username and answers.interactive:
794
+ username = read_line(KAGGLE_OWNER_PROMPT).strip()
795
+ from ..providers.kaggle import read_api_token
796
+
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)
800
+ if not username or not key:
801
+ raise LoginError(
802
+ f"{answers.alias} needs the Kaggle API token and notebook owner: pass "
803
+ f"--username <owner> and --key <KGAT_token> from kaggle.com, or drop --no-input."
804
+ )
805
+ if not key.startswith("KGAT_") or len(key) <= len("KGAT_") or any(
806
+ character.isspace() for character in key
807
+ ):
808
+ raise LoginError(
809
+ f"{answers.alias}: paste the Kaggle API token exactly as shown on kaggle.com, "
810
+ f"including KGAT_ and its nonempty body, with no whitespace."
811
+ )
812
+ return username, key
813
+
814
+
815
+ def kaggle_account(answers: Answers) -> dict[str, Any]:
816
+ """Store the browser session cookie and the API token, and prove the cookie is live.
817
+
818
+ Two credentials, and both are required. The cookie starts and ends the interactive
819
+ session and mints its Jupyter proxy URL; it is checked for shape and expiry before any
820
+ network call, then proven with a read of the account. The API token runs the official
821
+ CLI for everything else, deleting the notebook a run created and reading the weekly
822
+ quota; it is checked for shape only, since proving it would mean calling the CLI
823
+ from inside login, which this function leaves to the first command that uses it.
824
+ Nothing is written until every check passes.
782
825
  """
783
826
  from ..providers import kaggle as kg
784
827
 
828
+ username, key = read_kaggle_token(answers, answers.get("username"), answers.get("key"))
785
829
  given = answers.get(KAGGLE_COOKIE) or answers.token
786
830
  cookie = read_kaggle_cookie(answers, given if isinstance(given, str) else None)
787
831
  try:
@@ -802,6 +846,8 @@ def kaggle_account(answers: Answers) -> dict[str, Any]:
802
846
  options: dict[str, Any] = {"kind": answers.kind}
803
847
  record_workspace(answers, options)
804
848
  store_secret(answers.alias, KAGGLE_COOKIE, cookie)
849
+ store_secret(answers.alias, "access_token", key)
850
+ store_secret(answers.alias, "username", username)
805
851
  return options
806
852
 
807
853
 
@@ -947,6 +993,8 @@ def log_in(answers: Answers, *, project: str | Path | None = None) -> tuple[bool
947
993
  Returns whether the account was newly written, and the two files touched. An account
948
994
  already in the home file is not asked for again, which is the common case in a second
949
995
  repository: the account was set up once and this project just needs to name it.
996
+ ``replace`` runs the flow again for such an account, which is how a credential is
997
+ renewed. Spec "Logging in".
950
998
  """
951
999
  check_alias(answers.alias)
952
1000
  if answers.kind not in FLOWS:
@@ -955,7 +1003,8 @@ def log_in(answers: Answers, *, project: str | Path | None = None) -> tuple[bool
955
1003
 
956
1004
  home = home_path()
957
1005
  existing = home.read_text(encoding="utf-8") if home.is_file() else ""
958
- fresh = not writer.has_block(existing, answers.alias)
1006
+ declared = not writer.has_block(existing, answers.alias)
1007
+ fresh = declared or answers.replace
959
1008
  devices = None
960
1009
  if fresh:
961
1010
  options = FLOWS[answers.kind](answers)
@@ -161,7 +161,9 @@ class Function(Generic[R]):
161
161
  # provider may name the failure first, such as a spot preemption.
162
162
  exc = runtime.provider.diagnose(runtime, failure)
163
163
  last = exc
164
- launcher.pool.discard(runtime)
164
+ launcher.pool.discard(
165
+ runtime, reason=f"infrastructure_failure:{type(exc).__name__}: {exc}"
166
+ )
165
167
  if attempt == self.retries:
166
168
  if isinstance(exc, SpotPreempted):
167
169
  raise exc from failure
@@ -205,7 +207,9 @@ class Function(Generic[R]):
205
207
  _report_deferred(client.stats.snapshot() - before)
206
208
  except (RuntimeFailure, ProtocolError) as failure:
207
209
  named = runtime.provider.diagnose(runtime, failure)
208
- launcher.pool.discard(runtime)
210
+ launcher.pool.discard(
211
+ runtime, reason=f"infrastructure_failure:{type(named).__name__}: {named}"
212
+ )
209
213
  if named is failure:
210
214
  raise
211
215
  raise named from failure