letify 1.1.2__tar.gz → 1.2.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 (191) hide show
  1. {letify-1.1.2 → letify-1.2.0}/PKG-INFO +2 -2
  2. {letify-1.1.2 → letify-1.2.0}/README.md +1 -1
  3. {letify-1.1.2 → letify-1.2.0}/docs/guide/07-troubleshooting.md +1 -7
  4. {letify-1.1.2 → letify-1.2.0}/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.0}/docs/locales/README_ko.md +1 -1
  6. {letify-1.1.2 → letify-1.2.0}/letify/__init__.py +1 -1
  7. {letify-1.1.2 → letify-1.2.0}/letify/cli.py +157 -13
  8. {letify-1.1.2 → letify-1.2.0}/letify/config/login.py +57 -8
  9. {letify-1.1.2 → letify-1.2.0}/letify/declare/function.py +6 -2
  10. {letify-1.1.2 → letify-1.2.0}/letify/install.py +90 -0
  11. {letify-1.1.2 → letify-1.2.0}/letify/launcher.py +66 -1
  12. letify-1.2.0/letify/paths.py +168 -0
  13. {letify-1.1.2 → letify-1.2.0}/letify/protocol/codec.py +27 -0
  14. {letify-1.1.2 → letify-1.2.0}/letify/protocol/driver.py +16 -5
  15. {letify-1.1.2 → letify-1.2.0}/letify/protocol/worker.py +145 -1
  16. {letify-1.1.2 → letify-1.2.0}/letify/providers/base.py +1 -1
  17. {letify-1.1.2 → letify-1.2.0}/letify/providers/colab.py +133 -4
  18. {letify-1.1.2 → letify-1.2.0}/letify/providers/colab_files.py +5 -1
  19. letify-1.2.0/letify/providers/colab_keepalive.py +170 -0
  20. {letify-1.1.2 → letify-1.2.0}/letify/providers/kaggle.py +312 -98
  21. {letify-1.1.2 → letify-1.2.0}/letify/providers/local.py +5 -1
  22. {letify-1.1.2 → letify-1.2.0}/letify/providers/modal.py +5 -1
  23. {letify-1.1.2 → letify-1.2.0}/letify/providers/shell.py +21 -1
  24. {letify-1.1.2 → letify-1.2.0}/letify/runtime/bootstrap.py +82 -11
  25. {letify-1.1.2 → letify-1.2.0}/letify/runtime/lease.py +13 -2
  26. {letify-1.1.2 → letify-1.2.0}/letify/runtime/pool.py +29 -6
  27. {letify-1.1.2 → letify-1.2.0}/letify/runtime/session.py +77 -26
  28. {letify-1.1.2 → letify-1.2.0}/letify/store/backends/__init__.py +3 -2
  29. {letify-1.1.2 → letify-1.2.0}/letify/store/pathdata.py +113 -7
  30. {letify-1.1.2 → letify-1.2.0}/letify/store/volume.py +24 -8
  31. {letify-1.1.2 → letify-1.2.0}/letify/tools.py +132 -8
  32. {letify-1.1.2 → letify-1.2.0}/letify/transport/pipeline.py +134 -5
  33. {letify-1.1.2 → letify-1.2.0}/letify/transport/rendezvous.py +3 -0
  34. {letify-1.1.2 → letify-1.2.0}/letify/transport/sshopts.py +3 -2
  35. {letify-1.1.2 → letify-1.2.0}/letify/transport/strategies.py +5 -1
  36. {letify-1.1.2 → letify-1.2.0}/letify-core/Cargo.lock +3 -3
  37. {letify-1.1.2 → letify-1.2.0}/letify-core/Cargo.toml +1 -1
  38. {letify-1.1.2 → letify-1.2.0}/letify-ext/README.md +7 -5
  39. {letify-1.1.2 → letify-1.2.0}/letify-ext/package-lock.json +2 -2
  40. {letify-1.1.2 → letify-1.2.0}/letify-ext/package.json +5 -5
  41. {letify-1.1.2 → letify-1.2.0}/letify-ext/src/cli.ts +21 -1
  42. {letify-1.1.2 → letify-1.2.0}/letify-ext/src/extension.ts +26 -35
  43. {letify-1.1.2 → letify-1.2.0}/letify-ext/src/model.ts +48 -0
  44. letify-1.2.0/letify-ext/src/poll.ts +14 -0
  45. {letify-1.1.2 → letify-1.2.0}/letify-ext/src/render.ts +29 -19
  46. letify-1.2.0/letify-ext/test/cli.test.ts +37 -0
  47. letify-1.2.0/letify-ext/test/manifest.test.ts +16 -0
  48. letify-1.2.0/letify-ext/test/regressions.test.ts +89 -0
  49. {letify-1.1.2 → letify-1.2.0}/pyproject.toml +1 -1
  50. {letify-1.1.2 → letify-1.2.0}/tests/conftest.py +80 -1
  51. {letify-1.1.2 → letify-1.2.0}/tests/test_cli.py +37 -1
  52. letify-1.2.0/tests/test_colab_keepalive.py +101 -0
  53. {letify-1.1.2 → letify-1.2.0}/tests/test_data.py +15 -5
  54. {letify-1.1.2 → letify-1.2.0}/tests/test_data_streaming.py +160 -2
  55. letify-1.2.0/tests/test_editor_sessions.py +64 -0
  56. {letify-1.1.2 → letify-1.2.0}/tests/test_install.py +45 -0
  57. {letify-1.1.2 → letify-1.2.0}/tests/test_kaggle.py +358 -71
  58. {letify-1.1.2 → letify-1.2.0}/tests/test_link_log.py +7 -2
  59. letify-1.2.0/tests/test_notebook.py +55 -0
  60. letify-1.2.0/tests/test_paths.py +304 -0
  61. {letify-1.1.2 → letify-1.2.0}/tests/test_protocol.py +117 -0
  62. {letify-1.1.2 → letify-1.2.0}/tests/test_providers.py +116 -9
  63. {letify-1.1.2 → letify-1.2.0}/tests/test_runtime.py +259 -7
  64. {letify-1.1.2 → letify-1.2.0}/tests/test_store.py +30 -1
  65. {letify-1.1.2 → letify-1.2.0}/tests/test_transport.py +123 -6
  66. {letify-1.1.2 → letify-1.2.0}/.github/workflows/ci.yml +0 -0
  67. {letify-1.1.2 → letify-1.2.0}/.github/workflows/docs.yml +0 -0
  68. {letify-1.1.2 → letify-1.2.0}/.github/workflows/publish.yml +0 -0
  69. {letify-1.1.2 → letify-1.2.0}/.gitignore +0 -0
  70. {letify-1.1.2 → letify-1.2.0}/.researchtree +0 -0
  71. {letify-1.1.2 → letify-1.2.0}/LICENSE +0 -0
  72. {letify-1.1.2 → letify-1.2.0}/docs/guide/01-getting-started.md +0 -0
  73. {letify-1.1.2 → letify-1.2.0}/docs/guide/02-providers.md +0 -0
  74. {letify-1.1.2 → letify-1.2.0}/docs/guide/03-execution-modes.md +0 -0
  75. {letify-1.1.2 → letify-1.2.0}/docs/guide/04-environments-and-data.md +0 -0
  76. {letify-1.1.2 → letify-1.2.0}/docs/guide/05-concurrency.md +0 -0
  77. {letify-1.1.2 → letify-1.2.0}/docs/guide/06-cost.md +0 -0
  78. {letify-1.1.2 → letify-1.2.0}/docs/guide/08-releasing.md +0 -0
  79. {letify-1.1.2 → letify-1.2.0}/docs/guide/README.md +0 -0
  80. {letify-1.1.2 → letify-1.2.0}/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.0}/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.0}/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.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
  84. {letify-1.1.2 → letify-1.2.0}/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.0}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  86. {letify-1.1.2 → letify-1.2.0}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  87. {letify-1.1.2 → letify-1.2.0}/docs/guide/ko/README.md +0 -0
  88. {letify-1.1.2 → letify-1.2.0}/docs/mkdocs.yml +0 -0
  89. {letify-1.1.2 → letify-1.2.0}/examples/README.md +0 -0
  90. {letify-1.1.2 → letify-1.2.0}/examples/nvfp4-lora/.letify.example +0 -0
  91. {letify-1.1.2 → letify-1.2.0}/examples/nvfp4-lora/00_smoke.py +0 -0
  92. {letify-1.1.2 → letify-1.2.0}/examples/nvfp4-lora/01_sweep.py +0 -0
  93. {letify-1.1.2 → letify-1.2.0}/examples/nvfp4-lora/02_resume.py +0 -0
  94. {letify-1.1.2 → letify-1.2.0}/examples/nvfp4-lora/03_watch.py +0 -0
  95. {letify-1.1.2 → letify-1.2.0}/examples/nvfp4-lora/README.md +0 -0
  96. {letify-1.1.2 → letify-1.2.0}/examples/nvfp4-lora/README_ko.md +0 -0
  97. {letify-1.1.2 → letify-1.2.0}/examples/nvfp4-lora/common.py +0 -0
  98. {letify-1.1.2 → letify-1.2.0}/examples/nvfp4-lora/recipe.py +0 -0
  99. {letify-1.1.2 → letify-1.2.0}/letify/config/__init__.py +0 -0
  100. {letify-1.1.2 → letify-1.2.0}/letify/config/inventory.py +0 -0
  101. {letify-1.1.2 → letify-1.2.0}/letify/config/schema.py +0 -0
  102. {letify-1.1.2 → letify-1.2.0}/letify/config/secrets.py +0 -0
  103. {letify-1.1.2 → letify-1.2.0}/letify/config/writer.py +0 -0
  104. {letify-1.1.2 → letify-1.2.0}/letify/declare/__init__.py +0 -0
  105. {letify-1.1.2 → letify-1.2.0}/letify/declare/cache.py +0 -0
  106. {letify-1.1.2 → letify-1.2.0}/letify/declare/env.py +0 -0
  107. {letify-1.1.2 → letify-1.2.0}/letify/declare/instance.py +0 -0
  108. {letify-1.1.2 → letify-1.2.0}/letify/errors.py +0 -0
  109. {letify-1.1.2 → letify-1.2.0}/letify/protocol/__init__.py +0 -0
  110. {letify-1.1.2 → letify-1.2.0}/letify/protocol/handle.py +0 -0
  111. {letify-1.1.2 → letify-1.2.0}/letify/protocol/wire.py +0 -0
  112. {letify-1.1.2 → letify-1.2.0}/letify/providers/__init__.py +0 -0
  113. {letify-1.1.2 → letify-1.2.0}/letify/providers/elice.py +0 -0
  114. {letify-1.1.2 → letify-1.2.0}/letify/providers/kaggle_adapter.py +0 -0
  115. {letify-1.1.2 → letify-1.2.0}/letify/providers/modal_adapter.py +0 -0
  116. {letify-1.1.2 → letify-1.2.0}/letify/providers/naming.py +0 -0
  117. {letify-1.1.2 → letify-1.2.0}/letify/providers/tunnel.py +0 -0
  118. {letify-1.1.2 → letify-1.2.0}/letify/providers/usage.py +0 -0
  119. {letify-1.1.2 → letify-1.2.0}/letify/remoting/__init__.py +0 -0
  120. {letify-1.1.2 → letify-1.2.0}/letify/remoting/capability.py +0 -0
  121. {letify-1.1.2 → letify-1.2.0}/letify/remoting/device/__init__.py +0 -0
  122. {letify-1.1.2 → letify-1.2.0}/letify/remoting/device/client.py +0 -0
  123. {letify-1.1.2 → letify-1.2.0}/letify/remoting/device/cuda.py +0 -0
  124. {letify-1.1.2 → letify-1.2.0}/letify/remoting/device/executor.py +0 -0
  125. {letify-1.1.2 → letify-1.2.0}/letify/remoting/device/frames.py +0 -0
  126. {letify-1.1.2 → letify-1.2.0}/letify/remoting/device/guard.py +0 -0
  127. {letify-1.1.2 → letify-1.2.0}/letify/remoting/device/tensor.py +0 -0
  128. {letify-1.1.2 → letify-1.2.0}/letify/remoting/device/trace.py +0 -0
  129. {letify-1.1.2 → letify-1.2.0}/letify/remoting/device/value.py +0 -0
  130. {letify-1.1.2 → letify-1.2.0}/letify/remoting/loader.py +0 -0
  131. {letify-1.1.2 → letify-1.2.0}/letify/remoting/probe.py +0 -0
  132. {letify-1.1.2 → letify-1.2.0}/letify/render.py +0 -0
  133. {letify-1.1.2 → letify-1.2.0}/letify/runtime/__init__.py +0 -0
  134. {letify-1.1.2 → letify-1.2.0}/letify/runtime/channel.py +0 -0
  135. {letify-1.1.2 → letify-1.2.0}/letify/runtime/telemetry.py +0 -0
  136. {letify-1.1.2 → letify-1.2.0}/letify/store/__init__.py +0 -0
  137. {letify-1.1.2 → letify-1.2.0}/letify/store/backends/filesystem.py +0 -0
  138. {letify-1.1.2 → letify-1.2.0}/letify/store/backends/google_auth.py +0 -0
  139. {letify-1.1.2 → letify-1.2.0}/letify/store/backends/layout.py +0 -0
  140. {letify-1.1.2 → letify-1.2.0}/letify/store/backends/objects.py +0 -0
  141. {letify-1.1.2 → letify-1.2.0}/letify/store/cas.py +0 -0
  142. {letify-1.1.2 → letify-1.2.0}/letify/store/sendorder.py +0 -0
  143. {letify-1.1.2 → letify-1.2.0}/letify/stubs.py +0 -0
  144. {letify-1.1.2 → letify-1.2.0}/letify/transport/__init__.py +0 -0
  145. {letify-1.1.2 → letify-1.2.0}/letify/transport/agent.py +0 -0
  146. {letify-1.1.2 → letify-1.2.0}/letify/transport/announce.py +0 -0
  147. {letify-1.1.2 → letify-1.2.0}/letify/transport/link.py +0 -0
  148. {letify-1.1.2 → letify-1.2.0}/letify/transport/nat.py +0 -0
  149. {letify-1.1.2 → letify-1.2.0}/letify/transport/probe.py +0 -0
  150. {letify-1.1.2 → letify-1.2.0}/letify/transport/setup.py +0 -0
  151. {letify-1.1.2 → letify-1.2.0}/letify-core/.gitignore +0 -0
  152. {letify-1.1.2 → letify-1.2.0}/letify-core/build.py +0 -0
  153. {letify-1.1.2 → letify-1.2.0}/letify-core/letify-agent/Cargo.toml +0 -0
  154. {letify-1.1.2 → letify-1.2.0}/letify-core/letify-agent/src/driver.rs +0 -0
  155. {letify-1.1.2 → letify-1.2.0}/letify-core/letify-agent/src/main.rs +0 -0
  156. {letify-1.1.2 → letify-1.2.0}/letify-core/letify-driver/Cargo.toml +0 -0
  157. {letify-1.1.2 → letify-1.2.0}/letify-core/letify-driver/src/client.rs +0 -0
  158. {letify-1.1.2 → letify-1.2.0}/letify-core/letify-driver/src/exports.rs +0 -0
  159. {letify-1.1.2 → letify-1.2.0}/letify-core/letify-driver/src/image.rs +0 -0
  160. {letify-1.1.2 → letify-1.2.0}/letify-core/letify-driver/src/lib.rs +0 -0
  161. {letify-1.1.2 → letify-1.2.0}/letify-core/letify-driver/src/status.rs +0 -0
  162. {letify-1.1.2 → letify-1.2.0}/letify-core/letify-driver/src/table.rs +0 -0
  163. {letify-1.1.2 → letify-1.2.0}/letify-core/letify-wire/Cargo.toml +0 -0
  164. {letify-1.1.2 → letify-1.2.0}/letify-core/letify-wire/src/lib.rs +0 -0
  165. {letify-1.1.2 → letify-1.2.0}/letify-core/letify-wire/tests/throughput.rs +0 -0
  166. {letify-1.1.2 → letify-1.2.0}/letify-core/rust-toolchain.toml +0 -0
  167. {letify-1.1.2 → letify-1.2.0}/letify-ext/.vscodeignore +0 -0
  168. {letify-1.1.2 → letify-1.2.0}/letify-ext/LICENSE +0 -0
  169. {letify-1.1.2 → letify-1.2.0}/letify-ext/media/icon.svg +0 -0
  170. {letify-1.1.2 → letify-1.2.0}/letify-ext/src/history.ts +0 -0
  171. {letify-1.1.2 → letify-1.2.0}/letify-ext/test/model.test.ts +0 -0
  172. {letify-1.1.2 → letify-1.2.0}/letify-ext/tsconfig.json +0 -0
  173. {letify-1.1.2 → letify-1.2.0}/letify_providers.py +0 -0
  174. {letify-1.1.2 → letify-1.2.0}/scripts/sync-main.sh +0 -0
  175. {letify-1.1.2 → letify-1.2.0}/scripts/version.py +0 -0
  176. {letify-1.1.2 → letify-1.2.0}/tests/fake_kaggle_adapter.py +0 -0
  177. {letify-1.1.2 → letify-1.2.0}/tests/fake_modal_adapter.py +0 -0
  178. {letify-1.1.2 → letify-1.2.0}/tests/test_cli_output.py +0 -0
  179. {letify-1.1.2 → letify-1.2.0}/tests/test_config.py +0 -0
  180. {letify-1.1.2 → letify-1.2.0}/tests/test_core.py +0 -0
  181. {letify-1.1.2 → letify-1.2.0}/tests/test_declare.py +0 -0
  182. {letify-1.1.2 → letify-1.2.0}/tests/test_device.py +0 -0
  183. {letify-1.1.2 → letify-1.2.0}/tests/test_elice.py +0 -0
  184. {letify-1.1.2 → letify-1.2.0}/tests/test_frames.py +0 -0
  185. {letify-1.1.2 → letify-1.2.0}/tests/test_kaggle_cookie.py +0 -0
  186. {letify-1.1.2 → letify-1.2.0}/tests/test_remoting.py +0 -0
  187. {letify-1.1.2 → letify-1.2.0}/tests/test_render.py +0 -0
  188. {letify-1.1.2 → letify-1.2.0}/tests/test_strategies.py +0 -0
  189. {letify-1.1.2 → letify-1.2.0}/tests/test_stubs.py +0 -0
  190. {letify-1.1.2 → letify-1.2.0}/tests/test_tunnel_setup.py +0 -0
  191. {letify-1.1.2 → letify-1.2.0}/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.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
@@ -311,7 +311,7 @@ Provider
311
311
  | 🇰🇷 `Elice` | persistent | Korean GPU cloud, per-second billing |
312
312
  | 🏅 `Kaggle` | ephemeral | free weekly GPU hours, `host=letify.remote` only |
313
313
 
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.
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. 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
315
 
316
316
  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
317
 
@@ -286,7 +286,7 @@ Provider
286
286
  | 🇰🇷 `Elice` | persistent | Korean GPU cloud, per-second billing |
287
287
  | 🏅 `Kaggle` | ephemeral | free weekly GPU hours, `host=letify.remote` only |
288
288
 
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.
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. 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
290
 
291
291
  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
292
 
@@ -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 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).
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 홀펀칭을 포함한 모든 연결 전략을 바닥값에 묶어 둡니다: 측정값이 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)를 보세요.
274
268
 
275
269
  ### 모은 호출이 병렬로 돌지 않습니다
276
270
 
@@ -280,7 +280,7 @@ Provider
280
280
  | 🇰🇷 `Elice` | persistent | 한국 GPU 클라우드, 초 단위 과금 |
281
281
  | 🏅 `Kaggle` | ephemeral | 주간 무료 GPU 시간, `host=letify.remote` 전용 |
282
282
 
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 시간도 보여 줍니다.
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 시간도 보여 줍니다. 로그인은 노트북 owner인 Kaggle 사용자 이름과 kaggle.com에 표시된 `KGAT_` 포함 API 토큰도 입력받습니다. 토큰은 쿠키 옆 `access_token`에 0600 권한으로 저장하며, 모든 Kaggle CLI 명령에 `KAGGLE_API_TOKEN` 환경 변수로 전달합니다.
284
284
 
285
285
  Kaggle 편집기에서 할 일은 없습니다. letify가 계정에 비공개 노트북을 만들어 세션을 직접 시작하고, 프로그램이 끝나면 세션을 끝냅니다. 세션은 커널 셀 하나 안에서 워커 하나를 돌리고, 프레임은 base64 줄로 오갑니다. 서울에서 측정해 올리기 약 9 MiB/s, 내리기 약 1.5 MiB/s입니다. letify는 그 세션에서 선언된 환경을 `uv sync`로 만들고, 파일을 주고받고, 워커의 인터프리터를 검사하고, Kaggle이 세션을 끝낼 때까지 호출을 실행합니다. 세션은 20분 동안 쓰지 않거나 12시간이 지나면 끝납니다. keep-alive는 보내지 않습니다.
286
286
 
@@ -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.0"
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
@@ -205,6 +205,8 @@ def find(tool: str, *, link_cache: bool = True, say: Say = _say) -> str | None:
205
205
  if paths is not None and paths[0].is_file():
206
206
  recorded = _marker_version(paths[1]) if paths[1].is_file() else None
207
207
  if recorded is None or recorded == version_of(tool):
208
+ if not link_cache and recorded == version_of(tool) and cache_path(tool).is_file():
209
+ return str(cache_path(tool))
208
210
  return str(paths[0])
209
211
  cached = cache_path(tool)
210
212
  if cached.is_file():
@@ -517,6 +519,12 @@ def install(tool: str, *, say: Say = _say) -> Path:
517
519
  if final.exists():
518
520
  shutil.rmtree(final)
519
521
  os.replace(staging, final)
522
+ for entry in list(final.parent.iterdir()):
523
+ if entry.name != final.name and not entry.name.startswith("."):
524
+ if entry.is_dir():
525
+ shutil.rmtree(entry, ignore_errors=True)
526
+ else:
527
+ entry.unlink(missing_ok=True)
520
528
  except BaseException:
521
529
  shutil.rmtree(staging, ignore_errors=True)
522
530
  raise
@@ -604,3 +612,85 @@ __all__ = [
604
612
  "link",
605
613
  "where",
606
614
  ]
615
+
616
+
617
+ def remote_tailcat_source() -> str:
618
+ """Prepare the pinned Linux binary on Colab using this installer's release metadata."""
619
+ import inspect
620
+
621
+ return (
622
+ inspect.getsource(_remote_tailcat)
623
+ + f"\nbinary = _remote_tailcat({TAILCAT_VERSION!r}, "
624
+ + f"{TAILCAT_RELEASE.format(version=TAILCAT_VERSION)!r}, {TAILCAT_SHA256!r})\n"
625
+ + "import json\nprint('LETIFY-TAILCAT ' + json.dumps(binary))\n"
626
+ )
627
+
628
+
629
+ def _remote_tailcat(version, release, pins):
630
+ """Install a verified Linux tailcat using only the runtime's standard library."""
631
+ import hashlib
632
+ import io
633
+ import json
634
+ import os
635
+ import platform
636
+ import tarfile
637
+ import tempfile
638
+ import urllib.request
639
+ from pathlib import Path
640
+
641
+ arch = {"x86_64": "amd64", "aarch64": "arm64", "arm64": "arm64", "armv7l": "armv7"}.get(
642
+ platform.machine().lower()
643
+ )
644
+ asset = f"tailcat_{version}_linux_{arch}.tar.gz"
645
+ if platform.system() != "Linux" or asset not in pins:
646
+ raise RuntimeError(f"tailcat: unsupported runtime platform {platform.machine()}")
647
+ destination = Path.home() / ".letify" / "tools" / "tailcat" / version
648
+ binary = destination / "tailcat"
649
+ marker = destination / "verified.json"
650
+ if binary.is_file() and marker.is_file():
651
+ verified = json.loads(marker.read_text())
652
+ actual = hashlib.sha256(binary.read_bytes()).hexdigest()
653
+ if verified.get("archive") != pins[asset] or verified.get("binary") != actual:
654
+ raise RuntimeError("tailcat cached binary SHA-256 mismatch; refused")
655
+ return str(binary)
656
+ with urllib.request.urlopen(f"{release}/{asset}", timeout=60) as response:
657
+ data = response.read()
658
+ actual = hashlib.sha256(data).hexdigest()
659
+ if actual != pins[asset]:
660
+ raise RuntimeError(
661
+ f"tailcat archive SHA-256 mismatch: expected {pins[asset]}, got {actual}"
662
+ )
663
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive:
664
+ members = archive.getmembers()
665
+ if any(
666
+ Path(m.name).is_absolute()
667
+ or ".." in Path(m.name).parts
668
+ or m.isdev()
669
+ or m.islnk()
670
+ or m.issym()
671
+ for m in members
672
+ ):
673
+ raise RuntimeError("tailcat archive contains an unsafe member; refused")
674
+ candidates = [
675
+ m
676
+ for m in members
677
+ if m.isfile() and Path(m.name).name == "tailcat" and len(Path(m.name).parts) <= 2
678
+ ]
679
+ if len(candidates) != 1:
680
+ raise RuntimeError("tailcat archive must hold one regular tailcat binary")
681
+ payload = archive.extractfile(candidates[0]).read()
682
+ destination.mkdir(parents=True, exist_ok=True)
683
+ descriptor, temporary = tempfile.mkstemp(prefix=".tailcat-", dir=destination)
684
+ try:
685
+ with os.fdopen(descriptor, "wb") as handle:
686
+ handle.write(payload)
687
+ os.chmod(temporary, 0o555)
688
+ os.replace(temporary, binary)
689
+ finally:
690
+ if os.path.exists(temporary):
691
+ os.unlink(temporary)
692
+ marker.write_text(
693
+ json.dumps({"archive": actual, "binary": hashlib.sha256(payload).hexdigest()})
694
+ )
695
+ marker.chmod(0o444)
696
+ return str(binary)