letify 1.2.1__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.1 → letify-1.2.2}/PKG-INFO +1 -1
  2. {letify-1.2.1 → letify-1.2.2}/docs/guide/04-environments-and-data.md +1 -1
  3. {letify-1.2.1 → 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
  4. {letify-1.2.1 → letify-1.2.2}/letify/__init__.py +1 -1
  5. {letify-1.2.1 → letify-1.2.2}/letify/config/login.py +39 -7
  6. {letify-1.2.1 → letify-1.2.2}/letify/paths.py +1 -1
  7. {letify-1.2.1 → letify-1.2.2}/letify/protocol/driver.py +42 -13
  8. {letify-1.2.1 → letify-1.2.2}/letify/protocol/worker.py +20 -21
  9. {letify-1.2.1 → letify-1.2.2}/letify/providers/base.py +3 -3
  10. {letify-1.2.1 → letify-1.2.2}/letify/providers/colab.py +41 -39
  11. {letify-1.2.1 → letify-1.2.2}/letify/providers/colab_keepalive.py +24 -6
  12. {letify-1.2.1 → letify-1.2.2}/letify/providers/kaggle.py +10 -7
  13. {letify-1.2.1 → letify-1.2.2}/letify/runtime/channel.py +4 -2
  14. {letify-1.2.1 → letify-1.2.2}/letify/runtime/session.py +20 -7
  15. {letify-1.2.1 → letify-1.2.2}/letify/store/pathdata.py +113 -4
  16. {letify-1.2.1 → letify-1.2.2}/letify/tools.py +0 -23
  17. {letify-1.2.1 → letify-1.2.2}/letify/transport/rendezvous.py +16 -3
  18. {letify-1.2.1 → letify-1.2.2}/letify-core/Cargo.lock +3 -3
  19. {letify-1.2.1 → letify-1.2.2}/letify-core/Cargo.toml +1 -1
  20. {letify-1.2.1 → letify-1.2.2}/letify-ext/package-lock.json +2 -2
  21. {letify-1.2.1 → letify-1.2.2}/letify-ext/package.json +1 -1
  22. {letify-1.2.1 → letify-1.2.2}/pyproject.toml +1 -1
  23. {letify-1.2.1 → letify-1.2.2}/tests/test_colab_keepalive.py +22 -0
  24. {letify-1.2.1 → letify-1.2.2}/tests/test_kaggle.py +96 -3
  25. {letify-1.2.1 → letify-1.2.2}/tests/test_paths.py +1 -1
  26. {letify-1.2.1 → letify-1.2.2}/tests/test_protocol.py +16 -26
  27. {letify-1.2.1 → letify-1.2.2}/tests/test_providers.py +101 -6
  28. letify-1.2.2/tests/test_ship.py +239 -0
  29. {letify-1.2.1 → letify-1.2.2}/.github/workflows/ci.yml +0 -0
  30. {letify-1.2.1 → letify-1.2.2}/.github/workflows/docs.yml +0 -0
  31. {letify-1.2.1 → letify-1.2.2}/.github/workflows/publish.yml +0 -0
  32. {letify-1.2.1 → letify-1.2.2}/.gitignore +0 -0
  33. {letify-1.2.1 → letify-1.2.2}/.researchtree +0 -0
  34. {letify-1.2.1 → letify-1.2.2}/LICENSE +0 -0
  35. {letify-1.2.1 → letify-1.2.2}/README.md +0 -0
  36. {letify-1.2.1 → letify-1.2.2}/docs/guide/01-getting-started.md +0 -0
  37. {letify-1.2.1 → letify-1.2.2}/docs/guide/02-providers.md +0 -0
  38. {letify-1.2.1 → letify-1.2.2}/docs/guide/03-execution-modes.md +0 -0
  39. {letify-1.2.1 → letify-1.2.2}/docs/guide/05-concurrency.md +0 -0
  40. {letify-1.2.1 → letify-1.2.2}/docs/guide/06-cost.md +0 -0
  41. {letify-1.2.1 → letify-1.2.2}/docs/guide/07-troubleshooting.md +0 -0
  42. {letify-1.2.1 → letify-1.2.2}/docs/guide/08-releasing.md +0 -0
  43. {letify-1.2.1 → letify-1.2.2}/docs/guide/README.md +0 -0
  44. {letify-1.2.1 → letify-1.2.2}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  45. {letify-1.2.1 → 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
  46. {letify-1.2.1 → letify-1.2.2}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  47. {letify-1.2.1 → letify-1.2.2}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  48. {letify-1.2.1 → letify-1.2.2}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  49. {letify-1.2.1 → letify-1.2.2}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  50. {letify-1.2.1 → letify-1.2.2}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  51. {letify-1.2.1 → letify-1.2.2}/docs/guide/ko/README.md +0 -0
  52. {letify-1.2.1 → letify-1.2.2}/docs/locales/README_ko.md +0 -0
  53. {letify-1.2.1 → letify-1.2.2}/docs/mkdocs.yml +0 -0
  54. {letify-1.2.1 → letify-1.2.2}/examples/README.md +0 -0
  55. {letify-1.2.1 → letify-1.2.2}/examples/nvfp4-lora/.letify.example +0 -0
  56. {letify-1.2.1 → letify-1.2.2}/examples/nvfp4-lora/00_smoke.py +0 -0
  57. {letify-1.2.1 → letify-1.2.2}/examples/nvfp4-lora/01_sweep.py +0 -0
  58. {letify-1.2.1 → letify-1.2.2}/examples/nvfp4-lora/02_resume.py +0 -0
  59. {letify-1.2.1 → letify-1.2.2}/examples/nvfp4-lora/03_watch.py +0 -0
  60. {letify-1.2.1 → letify-1.2.2}/examples/nvfp4-lora/README.md +0 -0
  61. {letify-1.2.1 → letify-1.2.2}/examples/nvfp4-lora/README_ko.md +0 -0
  62. {letify-1.2.1 → letify-1.2.2}/examples/nvfp4-lora/common.py +0 -0
  63. {letify-1.2.1 → letify-1.2.2}/examples/nvfp4-lora/recipe.py +0 -0
  64. {letify-1.2.1 → letify-1.2.2}/letify/cli.py +0 -0
  65. {letify-1.2.1 → letify-1.2.2}/letify/config/__init__.py +0 -0
  66. {letify-1.2.1 → letify-1.2.2}/letify/config/inventory.py +0 -0
  67. {letify-1.2.1 → letify-1.2.2}/letify/config/schema.py +0 -0
  68. {letify-1.2.1 → letify-1.2.2}/letify/config/secrets.py +0 -0
  69. {letify-1.2.1 → letify-1.2.2}/letify/config/writer.py +0 -0
  70. {letify-1.2.1 → letify-1.2.2}/letify/declare/__init__.py +0 -0
  71. {letify-1.2.1 → letify-1.2.2}/letify/declare/cache.py +0 -0
  72. {letify-1.2.1 → letify-1.2.2}/letify/declare/env.py +0 -0
  73. {letify-1.2.1 → letify-1.2.2}/letify/declare/function.py +0 -0
  74. {letify-1.2.1 → letify-1.2.2}/letify/declare/instance.py +0 -0
  75. {letify-1.2.1 → letify-1.2.2}/letify/errors.py +0 -0
  76. {letify-1.2.1 → letify-1.2.2}/letify/install.py +0 -0
  77. {letify-1.2.1 → letify-1.2.2}/letify/launcher.py +0 -0
  78. {letify-1.2.1 → letify-1.2.2}/letify/protocol/__init__.py +0 -0
  79. {letify-1.2.1 → letify-1.2.2}/letify/protocol/codec.py +0 -0
  80. {letify-1.2.1 → letify-1.2.2}/letify/protocol/handle.py +0 -0
  81. {letify-1.2.1 → letify-1.2.2}/letify/protocol/wire.py +0 -0
  82. {letify-1.2.1 → letify-1.2.2}/letify/providers/__init__.py +0 -0
  83. {letify-1.2.1 → letify-1.2.2}/letify/providers/colab_files.py +0 -0
  84. {letify-1.2.1 → letify-1.2.2}/letify/providers/elice.py +0 -0
  85. {letify-1.2.1 → letify-1.2.2}/letify/providers/kaggle_adapter.py +0 -0
  86. {letify-1.2.1 → letify-1.2.2}/letify/providers/local.py +0 -0
  87. {letify-1.2.1 → letify-1.2.2}/letify/providers/modal.py +0 -0
  88. {letify-1.2.1 → letify-1.2.2}/letify/providers/modal_adapter.py +0 -0
  89. {letify-1.2.1 → letify-1.2.2}/letify/providers/naming.py +0 -0
  90. {letify-1.2.1 → letify-1.2.2}/letify/providers/shell.py +0 -0
  91. {letify-1.2.1 → letify-1.2.2}/letify/providers/tunnel.py +0 -0
  92. {letify-1.2.1 → letify-1.2.2}/letify/providers/usage.py +0 -0
  93. {letify-1.2.1 → letify-1.2.2}/letify/remoting/__init__.py +0 -0
  94. {letify-1.2.1 → letify-1.2.2}/letify/remoting/capability.py +0 -0
  95. {letify-1.2.1 → letify-1.2.2}/letify/remoting/device/__init__.py +0 -0
  96. {letify-1.2.1 → letify-1.2.2}/letify/remoting/device/client.py +0 -0
  97. {letify-1.2.1 → letify-1.2.2}/letify/remoting/device/cuda.py +0 -0
  98. {letify-1.2.1 → letify-1.2.2}/letify/remoting/device/executor.py +0 -0
  99. {letify-1.2.1 → letify-1.2.2}/letify/remoting/device/frames.py +0 -0
  100. {letify-1.2.1 → letify-1.2.2}/letify/remoting/device/guard.py +0 -0
  101. {letify-1.2.1 → letify-1.2.2}/letify/remoting/device/tensor.py +0 -0
  102. {letify-1.2.1 → letify-1.2.2}/letify/remoting/device/trace.py +0 -0
  103. {letify-1.2.1 → letify-1.2.2}/letify/remoting/device/value.py +0 -0
  104. {letify-1.2.1 → letify-1.2.2}/letify/remoting/loader.py +0 -0
  105. {letify-1.2.1 → letify-1.2.2}/letify/remoting/probe.py +0 -0
  106. {letify-1.2.1 → letify-1.2.2}/letify/render.py +0 -0
  107. {letify-1.2.1 → letify-1.2.2}/letify/runtime/__init__.py +0 -0
  108. {letify-1.2.1 → letify-1.2.2}/letify/runtime/bootstrap.py +0 -0
  109. {letify-1.2.1 → letify-1.2.2}/letify/runtime/lease.py +0 -0
  110. {letify-1.2.1 → letify-1.2.2}/letify/runtime/pool.py +0 -0
  111. {letify-1.2.1 → letify-1.2.2}/letify/runtime/telemetry.py +0 -0
  112. {letify-1.2.1 → letify-1.2.2}/letify/store/__init__.py +0 -0
  113. {letify-1.2.1 → letify-1.2.2}/letify/store/backends/__init__.py +0 -0
  114. {letify-1.2.1 → letify-1.2.2}/letify/store/backends/filesystem.py +0 -0
  115. {letify-1.2.1 → letify-1.2.2}/letify/store/backends/google_auth.py +0 -0
  116. {letify-1.2.1 → letify-1.2.2}/letify/store/backends/layout.py +0 -0
  117. {letify-1.2.1 → letify-1.2.2}/letify/store/backends/objects.py +0 -0
  118. {letify-1.2.1 → letify-1.2.2}/letify/store/cas.py +0 -0
  119. {letify-1.2.1 → letify-1.2.2}/letify/store/sendorder.py +0 -0
  120. {letify-1.2.1 → letify-1.2.2}/letify/store/volume.py +0 -0
  121. {letify-1.2.1 → letify-1.2.2}/letify/stubs.py +0 -0
  122. {letify-1.2.1 → letify-1.2.2}/letify/transport/__init__.py +0 -0
  123. {letify-1.2.1 → letify-1.2.2}/letify/transport/agent.py +0 -0
  124. {letify-1.2.1 → letify-1.2.2}/letify/transport/announce.py +0 -0
  125. {letify-1.2.1 → letify-1.2.2}/letify/transport/link.py +0 -0
  126. {letify-1.2.1 → letify-1.2.2}/letify/transport/nat.py +0 -0
  127. {letify-1.2.1 → letify-1.2.2}/letify/transport/pipeline.py +0 -0
  128. {letify-1.2.1 → letify-1.2.2}/letify/transport/probe.py +0 -0
  129. {letify-1.2.1 → letify-1.2.2}/letify/transport/setup.py +0 -0
  130. {letify-1.2.1 → letify-1.2.2}/letify/transport/sshopts.py +0 -0
  131. {letify-1.2.1 → letify-1.2.2}/letify/transport/strategies.py +0 -0
  132. {letify-1.2.1 → letify-1.2.2}/letify-core/.gitignore +0 -0
  133. {letify-1.2.1 → letify-1.2.2}/letify-core/build.py +0 -0
  134. {letify-1.2.1 → letify-1.2.2}/letify-core/letify-agent/Cargo.toml +0 -0
  135. {letify-1.2.1 → letify-1.2.2}/letify-core/letify-agent/src/driver.rs +0 -0
  136. {letify-1.2.1 → letify-1.2.2}/letify-core/letify-agent/src/main.rs +0 -0
  137. {letify-1.2.1 → letify-1.2.2}/letify-core/letify-driver/Cargo.toml +0 -0
  138. {letify-1.2.1 → letify-1.2.2}/letify-core/letify-driver/src/client.rs +0 -0
  139. {letify-1.2.1 → letify-1.2.2}/letify-core/letify-driver/src/exports.rs +0 -0
  140. {letify-1.2.1 → letify-1.2.2}/letify-core/letify-driver/src/image.rs +0 -0
  141. {letify-1.2.1 → letify-1.2.2}/letify-core/letify-driver/src/lib.rs +0 -0
  142. {letify-1.2.1 → letify-1.2.2}/letify-core/letify-driver/src/status.rs +0 -0
  143. {letify-1.2.1 → letify-1.2.2}/letify-core/letify-driver/src/table.rs +0 -0
  144. {letify-1.2.1 → letify-1.2.2}/letify-core/letify-wire/Cargo.toml +0 -0
  145. {letify-1.2.1 → letify-1.2.2}/letify-core/letify-wire/src/lib.rs +0 -0
  146. {letify-1.2.1 → letify-1.2.2}/letify-core/letify-wire/tests/throughput.rs +0 -0
  147. {letify-1.2.1 → letify-1.2.2}/letify-core/rust-toolchain.toml +0 -0
  148. {letify-1.2.1 → letify-1.2.2}/letify-ext/.vscodeignore +0 -0
  149. {letify-1.2.1 → letify-1.2.2}/letify-ext/LICENSE +0 -0
  150. {letify-1.2.1 → letify-1.2.2}/letify-ext/README.md +0 -0
  151. {letify-1.2.1 → letify-1.2.2}/letify-ext/media/icon.svg +0 -0
  152. {letify-1.2.1 → letify-1.2.2}/letify-ext/src/cli.ts +0 -0
  153. {letify-1.2.1 → letify-1.2.2}/letify-ext/src/extension.ts +0 -0
  154. {letify-1.2.1 → letify-1.2.2}/letify-ext/src/history.ts +0 -0
  155. {letify-1.2.1 → letify-1.2.2}/letify-ext/src/model.ts +0 -0
  156. {letify-1.2.1 → letify-1.2.2}/letify-ext/src/poll.ts +0 -0
  157. {letify-1.2.1 → letify-1.2.2}/letify-ext/src/render.ts +0 -0
  158. {letify-1.2.1 → letify-1.2.2}/letify-ext/test/cli.test.ts +0 -0
  159. {letify-1.2.1 → letify-1.2.2}/letify-ext/test/manifest.test.ts +0 -0
  160. {letify-1.2.1 → letify-1.2.2}/letify-ext/test/model.test.ts +0 -0
  161. {letify-1.2.1 → letify-1.2.2}/letify-ext/test/regressions.test.ts +0 -0
  162. {letify-1.2.1 → letify-1.2.2}/letify-ext/tsconfig.json +0 -0
  163. {letify-1.2.1 → letify-1.2.2}/letify_providers.py +0 -0
  164. {letify-1.2.1 → letify-1.2.2}/scripts/sync-main.sh +0 -0
  165. {letify-1.2.1 → letify-1.2.2}/scripts/version.py +0 -0
  166. {letify-1.2.1 → letify-1.2.2}/tests/conftest.py +0 -0
  167. {letify-1.2.1 → letify-1.2.2}/tests/fake_kaggle_adapter.py +0 -0
  168. {letify-1.2.1 → letify-1.2.2}/tests/fake_modal_adapter.py +0 -0
  169. {letify-1.2.1 → letify-1.2.2}/tests/test_cli.py +0 -0
  170. {letify-1.2.1 → letify-1.2.2}/tests/test_cli_output.py +0 -0
  171. {letify-1.2.1 → letify-1.2.2}/tests/test_config.py +0 -0
  172. {letify-1.2.1 → letify-1.2.2}/tests/test_core.py +0 -0
  173. {letify-1.2.1 → letify-1.2.2}/tests/test_data.py +0 -0
  174. {letify-1.2.1 → letify-1.2.2}/tests/test_data_streaming.py +0 -0
  175. {letify-1.2.1 → letify-1.2.2}/tests/test_declare.py +0 -0
  176. {letify-1.2.1 → letify-1.2.2}/tests/test_device.py +0 -0
  177. {letify-1.2.1 → letify-1.2.2}/tests/test_editor_sessions.py +0 -0
  178. {letify-1.2.1 → letify-1.2.2}/tests/test_elice.py +0 -0
  179. {letify-1.2.1 → letify-1.2.2}/tests/test_frames.py +0 -0
  180. {letify-1.2.1 → letify-1.2.2}/tests/test_install.py +0 -0
  181. {letify-1.2.1 → letify-1.2.2}/tests/test_kaggle_cookie.py +0 -0
  182. {letify-1.2.1 → letify-1.2.2}/tests/test_link_log.py +0 -0
  183. {letify-1.2.1 → letify-1.2.2}/tests/test_notebook.py +0 -0
  184. {letify-1.2.1 → letify-1.2.2}/tests/test_remoting.py +0 -0
  185. {letify-1.2.1 → letify-1.2.2}/tests/test_render.py +0 -0
  186. {letify-1.2.1 → letify-1.2.2}/tests/test_runtime.py +0 -0
  187. {letify-1.2.1 → letify-1.2.2}/tests/test_store.py +0 -0
  188. {letify-1.2.1 → letify-1.2.2}/tests/test_strategies.py +0 -0
  189. {letify-1.2.1 → letify-1.2.2}/tests/test_stubs.py +0 -0
  190. {letify-1.2.1 → letify-1.2.2}/tests/test_transport.py +0 -0
  191. {letify-1.2.1 → letify-1.2.2}/tests/test_tunnel_setup.py +0 -0
  192. {letify-1.2.1 → 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.1
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
@@ -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.
@@ -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이고, 비싼 것은 환경과 데이터이며 그건 캐시됩니다.
@@ -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.1"
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,30 +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"):
284
- try:
285
- return self._cli("exec", "-s", session, stdin=source, timeout=timeout)
286
- except subprocess.TimeoutExpired as exc:
287
- stderr = exc.stderr or ""
288
- if isinstance(stderr, bytes):
289
- stderr = stderr.decode(errors="replace")
290
- raise RuntimeFailure(
291
- f"{session}: colab exec timed out after {timeout} s; "
292
- "the notebook kernel may be busy or not answering",
293
- command=" ".join([*self._colab(), "exec", "-s", session]),
294
- stderr=stderr,
295
- ) from exc
296
- except RuntimeFailure as exc:
297
- if "Timeout waiting for reply" not in exc.stderr:
298
- raise
299
- raise RuntimeFailure(
300
- f"{session}: colab exec did not receive a reply; "
301
- "the notebook kernel may be busy or not answering",
302
- command=exc.command,
303
- stderr=exc.stderr,
304
- ) from exc
281
+ try:
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
305
302
 
306
303
  def sessions(self) -> list[str]:
307
304
  """Names of the sessions this account currently holds."""
@@ -329,8 +326,27 @@ class Colab(Shell):
329
326
  from ..transport.rendezvous import ColabRendezvous
330
327
 
331
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
+
332
346
  return ColabRendezvous(
333
- 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,
334
350
  )
335
351
 
336
352
  def fallback(self, runtime: Runtime | None = None) -> Callable[[], Link] | None:
@@ -350,20 +366,6 @@ class Colab(Shell):
350
366
  def target(self, runtime: Runtime | None = None) -> Target:
351
367
  target = super().target(runtime)
352
368
  target.user = self.user or "root"
353
- if runtime is not None and target.rendezvous.unavailable() is None:
354
- from .. import install
355
-
356
- if install.find("tailcat", link_cache=False) or self.config.option("tailcat_binary"):
357
- output = self._exec(runtime.name, install.remote_tailcat_source(), 180)
358
- for line in output.splitlines():
359
- if line.startswith("LETIFY-TAILCAT "):
360
- target.rendezvous.binary = json.loads(line[len("LETIFY-TAILCAT ") :])
361
- break
362
- else:
363
- raise RuntimeFailure(
364
- "tailcat installation gave no verified binary path",
365
- stderr=output.strip(),
366
- )
367
369
  if runtime is not None:
368
370
  # Each runtime is a new VM with a new host key.
369
371
  target.host_key_alias = f"letify-{self.alias}-{runtime.name}"
@@ -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
 
@@ -974,7 +974,10 @@ class Kaggle(Provider):
974
974
  """The fixed list of accelerators a Kaggle session offers. No call is made."""
975
975
  table: dict[str, Instance] = {"CPU": Instance(self, gpu=None)}
976
976
  table.update(
977
- {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
+ }
978
981
  )
979
982
  table.update({name: Instance(self, tpu=name) for name in TPUS})
980
983
  return table
@@ -1050,7 +1053,7 @@ class Kaggle(Provider):
1050
1053
  cookie = self._require_live_cookie()
1051
1054
  instance = getattr(runtime, "instance", None)
1052
1055
  gpu = getattr(instance, "gpu", None)
1053
- accelerator = GPUS[gpu]["accelerator"] if gpu in GPUS else None
1056
+ accelerator = GPUS[gpu]["accelerator"] if gpu in GPUS else getattr(instance, "tpu", None)
1054
1057
  run, url, notebook, slug = live_session_url(self.alias, cookie, accelerator)
1055
1058
  session = Session(self.alias, url)
1056
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
@@ -276,13 +276,11 @@ class Runtime:
276
276
  """
277
277
  if self.channel is None:
278
278
  raise RuntimeFailure(f"{self.name}: the channel is not open")
279
- # Done here rather than at declaration time, because the registration lives in
280
- # cloudpickle and a process can hold declarations with different environments.
281
- if self.env.ship_modules:
282
- protocol.codec.ship_by_value(self.env.ship_modules)
283
279
  if not self.persistent_channel:
284
280
  self.last_used = time.monotonic()
285
- return self.channel.call(fn, args, kwargs, timeout=timeout)
281
+ return self.channel.call(
282
+ fn, args, kwargs, timeout=timeout, modules=self.env.ship_modules
283
+ )
286
284
  args, kwargs = self._externalize(args, kwargs)
287
285
  return self._call_with_data(fn, args, kwargs, timeout, data_order, data_first_wave)
288
286
 
@@ -308,17 +306,27 @@ class Runtime:
308
306
  assert self.channel is not None
309
307
  root = (self.workspace or self.provider.workspace_root).rstrip("/")
310
308
  call_dir = f"{root}/data/calls/{secrets.token_hex(8)}"
309
+ modules_root = f"{root}/modules"
311
310
  collector = pathdata.Collector(call_dir)
311
+ ship_digests: set[str] = set()
312
+ if self.env.ship_modules:
313
+ for name in self.env.ship_modules:
314
+ placed = collector.collect_module(name, modules_root)
315
+ for _rel, digest, _size, _loc in placed.entries:
316
+ ship_digests.add(digest)
312
317
  head, buffers = protocol.dumps_call_parts(fn, args, kwargs, data=collector)
313
318
  request: dict[str, Any] = {"op": "call", "payload": head, "buffers": buffers}
314
319
  blobs = f"{root}/data/blobs"
315
320
  stream: pathdata.Stream | None = None
321
+ if self.env.ship_modules:
322
+ request["modules_dir"] = modules_root
316
323
  if collector.placed:
317
- request["data"] = collector.request(blobs)
324
+ extra_dirs = [modules_root] if self.env.ship_modules else ()
325
+ request["data"] = collector.request(blobs, extra_dirs=extra_dirs)
318
326
  if collector.inputs:
319
327
  stream = self._stream_data(
320
328
  collector, request["data"], blobs, fn, args, kwargs,
321
- data_order, data_first_wave,
329
+ data_order, data_first_wave, ship_digests=ship_digests,
322
330
  )
323
331
  self.last_used = time.monotonic()
324
332
  outcome = None
@@ -351,6 +359,7 @@ class Runtime:
351
359
  kwargs: dict,
352
360
  data_order: Any,
353
361
  data_first_wave: int | None,
362
+ ship_digests: set[str] | None = None,
354
363
  ) -> Any:
355
364
  """Derive the send order, place the first wave, and start the background sender."""
356
365
  from ..store import pathdata, sendorder
@@ -367,6 +376,10 @@ class Runtime:
367
376
  files=int(option("data_first_wave_files", pathdata.FIRST_WAVE_FILES)),
368
377
  count=data_first_wave,
369
378
  )
379
+ if ship_digests:
380
+ needed = [d for d in plan.missing if d in ship_digests and d not in wave]
381
+ if needed:
382
+ wave = needed + wave
370
383
  stream = pathdata.Stream(self, blobs, plan)
371
384
  if not plan.missing:
372
385
  # Nothing to stream: the runtime holds every file, so the call places them all