letify 2.7.1__tar.gz → 2.8.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 (214) hide show
  1. {letify-2.7.1 → letify-2.8.0}/PKG-INFO +1 -1
  2. {letify-2.7.1 → letify-2.8.0}/PROJECT.md +10 -0
  3. {letify-2.7.1 → letify-2.8.0}/docs/SPEC.md +37 -1
  4. {letify-2.7.1 → letify-2.8.0}/docs/guide/04-environments-and-data.md +9 -4
  5. {letify-2.7.1 → letify-2.8.0}/docs/guide/ko/04-/355/231/230/352/262/275/352/263/274-/353/215/260/354/235/264/355/204/260.md +9 -4
  6. {letify-2.7.1 → letify-2.8.0}/letify/__init__.py +27 -1
  7. {letify-2.7.1 → letify-2.8.0}/letify/config/__init__.py +41 -0
  8. {letify-2.7.1 → letify-2.8.0}/letify/protocol/codec.py +14 -0
  9. {letify-2.7.1 → letify-2.8.0}/letify/protocol/driver.py +3 -0
  10. {letify-2.7.1 → letify-2.8.0}/letify/protocol/wire.py +6 -0
  11. {letify-2.7.1 → letify-2.8.0}/letify/protocol/worker.py +82 -3
  12. {letify-2.7.1 → letify-2.8.0}/letify/providers/base.py +21 -0
  13. {letify-2.7.1 → letify-2.8.0}/letify/providers/colab.py +1 -1
  14. {letify-2.7.1 → letify-2.8.0}/letify/providers/modal.py +31 -1
  15. {letify-2.7.1 → letify-2.8.0}/letify/providers/modal_adapter.py +9 -0
  16. {letify-2.7.1 → letify-2.8.0}/letify/providers/shell.py +1 -0
  17. {letify-2.7.1 → letify-2.8.0}/letify/runtime/bootstrap.py +1 -1
  18. {letify-2.7.1 → letify-2.8.0}/letify/runtime/channel.py +65 -8
  19. {letify-2.7.1 → letify-2.8.0}/letify/runtime/lease.py +17 -0
  20. {letify-2.7.1 → letify-2.8.0}/letify/runtime/pool.py +1 -0
  21. {letify-2.7.1 → letify-2.8.0}/letify/runtime/session.py +180 -32
  22. letify-2.8.0/letify/store/backends/drive.py +434 -0
  23. {letify-2.7.1 → letify-2.8.0}/letify/store/backends/google_auth.py +6 -2
  24. {letify-2.7.1 → letify-2.8.0}/letify/store/cas.py +16 -0
  25. letify-2.8.0/letify/store/handoff.py +429 -0
  26. {letify-2.7.1 → letify-2.8.0}/letify/store/volume.py +65 -6
  27. {letify-2.7.1 → letify-2.8.0}/letify-core/Cargo.lock +3 -3
  28. {letify-2.7.1 → letify-2.8.0}/letify-core/Cargo.toml +1 -1
  29. {letify-2.7.1 → letify-2.8.0}/letify-ext/package-lock.json +2 -2
  30. {letify-2.7.1 → letify-2.8.0}/letify-ext/package.json +1 -1
  31. {letify-2.7.1 → letify-2.8.0}/pyproject.toml +1 -1
  32. {letify-2.7.1 → letify-2.8.0}/tests/conftest.py +266 -0
  33. {letify-2.7.1 → letify-2.8.0}/tests/fake_modal_adapter.py +6 -1
  34. letify-2.8.0/tests/test_drive_handoff.py +427 -0
  35. letify-2.8.0/tests/test_drive_store.py +443 -0
  36. {letify-2.7.1 → letify-2.8.0}/tests/test_providers.py +73 -0
  37. {letify-2.7.1 → letify-2.8.0}/tests/test_runtime.py +86 -0
  38. {letify-2.7.1 → letify-2.8.0}/.github/workflows/ci.yml +0 -0
  39. {letify-2.7.1 → letify-2.8.0}/.github/workflows/docs.yml +0 -0
  40. {letify-2.7.1 → letify-2.8.0}/.github/workflows/publish.yml +0 -0
  41. {letify-2.7.1 → letify-2.8.0}/.gitignore +0 -0
  42. {letify-2.7.1 → letify-2.8.0}/.researchtree +0 -0
  43. {letify-2.7.1 → letify-2.8.0}/CLAUDE.md +0 -0
  44. {letify-2.7.1 → letify-2.8.0}/LICENSE +0 -0
  45. {letify-2.7.1 → letify-2.8.0}/README.md +0 -0
  46. {letify-2.7.1 → letify-2.8.0}/docs/COMPONENT.md +0 -0
  47. {letify-2.7.1 → letify-2.8.0}/docs/INTENT.md +0 -0
  48. {letify-2.7.1 → letify-2.8.0}/docs/NETWORK.md +0 -0
  49. {letify-2.7.1 → letify-2.8.0}/docs/guide/01-getting-started.md +0 -0
  50. {letify-2.7.1 → letify-2.8.0}/docs/guide/02-providers.md +0 -0
  51. {letify-2.7.1 → letify-2.8.0}/docs/guide/03-execution-modes.md +0 -0
  52. {letify-2.7.1 → letify-2.8.0}/docs/guide/05-concurrency.md +0 -0
  53. {letify-2.7.1 → letify-2.8.0}/docs/guide/06-cost.md +0 -0
  54. {letify-2.7.1 → letify-2.8.0}/docs/guide/07-troubleshooting.md +0 -0
  55. {letify-2.7.1 → letify-2.8.0}/docs/guide/08-releasing.md +0 -0
  56. {letify-2.7.1 → letify-2.8.0}/docs/guide/README.md +0 -0
  57. {letify-2.7.1 → letify-2.8.0}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  58. {letify-2.7.1 → letify-2.8.0}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  59. {letify-2.7.1 → letify-2.8.0}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  60. {letify-2.7.1 → letify-2.8.0}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  61. {letify-2.7.1 → letify-2.8.0}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  62. {letify-2.7.1 → letify-2.8.0}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  63. {letify-2.7.1 → letify-2.8.0}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  64. {letify-2.7.1 → letify-2.8.0}/docs/guide/ko/README.md +0 -0
  65. {letify-2.7.1 → letify-2.8.0}/docs/locales/README_ko.md +0 -0
  66. {letify-2.7.1 → letify-2.8.0}/docs/mkdocs.yml +0 -0
  67. {letify-2.7.1 → letify-2.8.0}/examples/README.md +0 -0
  68. {letify-2.7.1 → letify-2.8.0}/examples/nvfp4-lora/.letify.example +0 -0
  69. {letify-2.7.1 → letify-2.8.0}/examples/nvfp4-lora/00_smoke.py +0 -0
  70. {letify-2.7.1 → letify-2.8.0}/examples/nvfp4-lora/01_sweep.py +0 -0
  71. {letify-2.7.1 → letify-2.8.0}/examples/nvfp4-lora/02_resume.py +0 -0
  72. {letify-2.7.1 → letify-2.8.0}/examples/nvfp4-lora/03_watch.py +0 -0
  73. {letify-2.7.1 → letify-2.8.0}/examples/nvfp4-lora/README.md +0 -0
  74. {letify-2.7.1 → letify-2.8.0}/examples/nvfp4-lora/README_ko.md +0 -0
  75. {letify-2.7.1 → letify-2.8.0}/examples/nvfp4-lora/common.py +0 -0
  76. {letify-2.7.1 → letify-2.8.0}/examples/nvfp4-lora/recipe.py +0 -0
  77. {letify-2.7.1 → letify-2.8.0}/letify/cli.py +0 -0
  78. {letify-2.7.1 → letify-2.8.0}/letify/config/inventory.py +0 -0
  79. {letify-2.7.1 → letify-2.8.0}/letify/config/login.py +0 -0
  80. {letify-2.7.1 → letify-2.8.0}/letify/config/schema.py +0 -0
  81. {letify-2.7.1 → letify-2.8.0}/letify/config/secrets.py +0 -0
  82. {letify-2.7.1 → letify-2.8.0}/letify/config/writer.py +0 -0
  83. {letify-2.7.1 → letify-2.8.0}/letify/declare/__init__.py +0 -0
  84. {letify-2.7.1 → letify-2.8.0}/letify/declare/cache.py +0 -0
  85. {letify-2.7.1 → letify-2.8.0}/letify/declare/env.py +0 -0
  86. {letify-2.7.1 → letify-2.8.0}/letify/declare/function.py +0 -0
  87. {letify-2.7.1 → letify-2.8.0}/letify/declare/instance.py +0 -0
  88. {letify-2.7.1 → letify-2.8.0}/letify/declare/reduce.py +0 -0
  89. {letify-2.7.1 → letify-2.8.0}/letify/declare/replicas.py +0 -0
  90. {letify-2.7.1 → letify-2.8.0}/letify/errors.py +0 -0
  91. {letify-2.7.1 → letify-2.8.0}/letify/install.py +0 -0
  92. {letify-2.7.1 → letify-2.8.0}/letify/launcher.py +0 -0
  93. {letify-2.7.1 → letify-2.8.0}/letify/paths.py +0 -0
  94. {letify-2.7.1 → letify-2.8.0}/letify/protocol/__init__.py +0 -0
  95. {letify-2.7.1 → letify-2.8.0}/letify/protocol/handle.py +0 -0
  96. {letify-2.7.1 → letify-2.8.0}/letify/providers/__init__.py +0 -0
  97. {letify-2.7.1 → letify-2.8.0}/letify/providers/colab_files.py +0 -0
  98. {letify-2.7.1 → letify-2.8.0}/letify/providers/colab_keepalive.py +0 -0
  99. {letify-2.7.1 → letify-2.8.0}/letify/providers/elice.py +0 -0
  100. {letify-2.7.1 → letify-2.8.0}/letify/providers/kaggle.py +0 -0
  101. {letify-2.7.1 → letify-2.8.0}/letify/providers/kaggle_adapter.py +0 -0
  102. {letify-2.7.1 → letify-2.8.0}/letify/providers/local.py +0 -0
  103. {letify-2.7.1 → letify-2.8.0}/letify/providers/naming.py +0 -0
  104. {letify-2.7.1 → letify-2.8.0}/letify/providers/tunnel.py +0 -0
  105. {letify-2.7.1 → letify-2.8.0}/letify/providers/usage.py +0 -0
  106. {letify-2.7.1 → letify-2.8.0}/letify/remoting/__init__.py +0 -0
  107. {letify-2.7.1 → letify-2.8.0}/letify/remoting/capability.py +0 -0
  108. {letify-2.7.1 → letify-2.8.0}/letify/remoting/device/__init__.py +0 -0
  109. {letify-2.7.1 → letify-2.8.0}/letify/remoting/device/client.py +0 -0
  110. {letify-2.7.1 → letify-2.8.0}/letify/remoting/device/cuda.py +0 -0
  111. {letify-2.7.1 → letify-2.8.0}/letify/remoting/device/executor.py +0 -0
  112. {letify-2.7.1 → letify-2.8.0}/letify/remoting/device/frames.py +0 -0
  113. {letify-2.7.1 → letify-2.8.0}/letify/remoting/device/guard.py +0 -0
  114. {letify-2.7.1 → letify-2.8.0}/letify/remoting/device/tensor.py +0 -0
  115. {letify-2.7.1 → letify-2.8.0}/letify/remoting/device/trace.py +0 -0
  116. {letify-2.7.1 → letify-2.8.0}/letify/remoting/device/value.py +0 -0
  117. {letify-2.7.1 → letify-2.8.0}/letify/remoting/loader.py +0 -0
  118. {letify-2.7.1 → letify-2.8.0}/letify/remoting/probe.py +0 -0
  119. {letify-2.7.1 → letify-2.8.0}/letify/render.py +0 -0
  120. {letify-2.7.1 → letify-2.8.0}/letify/runtime/__init__.py +0 -0
  121. {letify-2.7.1 → letify-2.8.0}/letify/runtime/broker.py +0 -0
  122. {letify-2.7.1 → letify-2.8.0}/letify/runtime/telemetry.py +0 -0
  123. {letify-2.7.1 → letify-2.8.0}/letify/store/__init__.py +0 -0
  124. {letify-2.7.1 → letify-2.8.0}/letify/store/backends/__init__.py +0 -0
  125. {letify-2.7.1 → letify-2.8.0}/letify/store/backends/filesystem.py +0 -0
  126. {letify-2.7.1 → letify-2.8.0}/letify/store/backends/layout.py +0 -0
  127. {letify-2.7.1 → letify-2.8.0}/letify/store/backends/objects.py +0 -0
  128. {letify-2.7.1 → letify-2.8.0}/letify/store/pathdata.py +0 -0
  129. {letify-2.7.1 → letify-2.8.0}/letify/store/sendorder.py +0 -0
  130. {letify-2.7.1 → letify-2.8.0}/letify/stubs.py +0 -0
  131. {letify-2.7.1 → letify-2.8.0}/letify/tools.py +0 -0
  132. {letify-2.7.1 → letify-2.8.0}/letify/transport/__init__.py +0 -0
  133. {letify-2.7.1 → letify-2.8.0}/letify/transport/agent.py +0 -0
  134. {letify-2.7.1 → letify-2.8.0}/letify/transport/announce.py +0 -0
  135. {letify-2.7.1 → letify-2.8.0}/letify/transport/link.py +0 -0
  136. {letify-2.7.1 → letify-2.8.0}/letify/transport/nat.py +0 -0
  137. {letify-2.7.1 → letify-2.8.0}/letify/transport/pipeline.py +0 -0
  138. {letify-2.7.1 → letify-2.8.0}/letify/transport/portmap.py +0 -0
  139. {letify-2.7.1 → letify-2.8.0}/letify/transport/probe.py +0 -0
  140. {letify-2.7.1 → letify-2.8.0}/letify/transport/quic.py +0 -0
  141. {letify-2.7.1 → letify-2.8.0}/letify/transport/rendezvous.py +0 -0
  142. {letify-2.7.1 → letify-2.8.0}/letify/transport/setup.py +0 -0
  143. {letify-2.7.1 → letify-2.8.0}/letify/transport/sshopts.py +0 -0
  144. {letify-2.7.1 → letify-2.8.0}/letify/transport/strategies.py +0 -0
  145. {letify-2.7.1 → letify-2.8.0}/letify-core/.gitignore +0 -0
  146. {letify-2.7.1 → letify-2.8.0}/letify-core/build.py +0 -0
  147. {letify-2.7.1 → letify-2.8.0}/letify-core/letify-agent/Cargo.toml +0 -0
  148. {letify-2.7.1 → letify-2.8.0}/letify-core/letify-agent/src/driver.rs +0 -0
  149. {letify-2.7.1 → letify-2.8.0}/letify-core/letify-agent/src/main.rs +0 -0
  150. {letify-2.7.1 → letify-2.8.0}/letify-core/letify-driver/Cargo.toml +0 -0
  151. {letify-2.7.1 → letify-2.8.0}/letify-core/letify-driver/src/client.rs +0 -0
  152. {letify-2.7.1 → letify-2.8.0}/letify-core/letify-driver/src/exports.rs +0 -0
  153. {letify-2.7.1 → letify-2.8.0}/letify-core/letify-driver/src/image.rs +0 -0
  154. {letify-2.7.1 → letify-2.8.0}/letify-core/letify-driver/src/lib.rs +0 -0
  155. {letify-2.7.1 → letify-2.8.0}/letify-core/letify-driver/src/status.rs +0 -0
  156. {letify-2.7.1 → letify-2.8.0}/letify-core/letify-driver/src/table.rs +0 -0
  157. {letify-2.7.1 → letify-2.8.0}/letify-core/letify-quic/Cargo.toml +0 -0
  158. {letify-2.7.1 → letify-2.8.0}/letify-core/letify-quic/src/main.rs +0 -0
  159. {letify-2.7.1 → letify-2.8.0}/letify-core/letify-wire/Cargo.toml +0 -0
  160. {letify-2.7.1 → letify-2.8.0}/letify-core/letify-wire/src/lib.rs +0 -0
  161. {letify-2.7.1 → letify-2.8.0}/letify-core/letify-wire/tests/throughput.rs +0 -0
  162. {letify-2.7.1 → letify-2.8.0}/letify-core/rust-toolchain.toml +0 -0
  163. {letify-2.7.1 → letify-2.8.0}/letify-ext/.vscodeignore +0 -0
  164. {letify-2.7.1 → letify-2.8.0}/letify-ext/LICENSE +0 -0
  165. {letify-2.7.1 → letify-2.8.0}/letify-ext/README.md +0 -0
  166. {letify-2.7.1 → letify-2.8.0}/letify-ext/media/icon.svg +0 -0
  167. {letify-2.7.1 → letify-2.8.0}/letify-ext/src/cli.ts +0 -0
  168. {letify-2.7.1 → letify-2.8.0}/letify-ext/src/extension.ts +0 -0
  169. {letify-2.7.1 → letify-2.8.0}/letify-ext/src/history.ts +0 -0
  170. {letify-2.7.1 → letify-2.8.0}/letify-ext/src/model.ts +0 -0
  171. {letify-2.7.1 → letify-2.8.0}/letify-ext/src/poll.ts +0 -0
  172. {letify-2.7.1 → letify-2.8.0}/letify-ext/src/render.ts +0 -0
  173. {letify-2.7.1 → letify-2.8.0}/letify-ext/test/cli.test.ts +0 -0
  174. {letify-2.7.1 → letify-2.8.0}/letify-ext/test/manifest.test.ts +0 -0
  175. {letify-2.7.1 → letify-2.8.0}/letify-ext/test/model.test.ts +0 -0
  176. {letify-2.7.1 → letify-2.8.0}/letify-ext/test/regressions.test.ts +0 -0
  177. {letify-2.7.1 → letify-2.8.0}/letify-ext/tsconfig.json +0 -0
  178. {letify-2.7.1 → letify-2.8.0}/letify_providers.py +0 -0
  179. {letify-2.7.1 → letify-2.8.0}/scripts/sync-main.sh +0 -0
  180. {letify-2.7.1 → letify-2.8.0}/scripts/version.py +0 -0
  181. {letify-2.7.1 → letify-2.8.0}/tests/fake_kaggle_adapter.py +0 -0
  182. {letify-2.7.1 → letify-2.8.0}/tests/test_cli.py +0 -0
  183. {letify-2.7.1 → letify-2.8.0}/tests/test_cli_output.py +0 -0
  184. {letify-2.7.1 → letify-2.8.0}/tests/test_colab_keepalive.py +0 -0
  185. {letify-2.7.1 → letify-2.8.0}/tests/test_config.py +0 -0
  186. {letify-2.7.1 → letify-2.8.0}/tests/test_core.py +0 -0
  187. {letify-2.7.1 → letify-2.8.0}/tests/test_data.py +0 -0
  188. {letify-2.7.1 → letify-2.8.0}/tests/test_data_streaming.py +0 -0
  189. {letify-2.7.1 → letify-2.8.0}/tests/test_declare.py +0 -0
  190. {letify-2.7.1 → letify-2.8.0}/tests/test_device.py +0 -0
  191. {letify-2.7.1 → letify-2.8.0}/tests/test_distributed.py +0 -0
  192. {letify-2.7.1 → letify-2.8.0}/tests/test_editor_sessions.py +0 -0
  193. {letify-2.7.1 → letify-2.8.0}/tests/test_elice.py +0 -0
  194. {letify-2.7.1 → letify-2.8.0}/tests/test_frames.py +0 -0
  195. {letify-2.7.1 → letify-2.8.0}/tests/test_install.py +0 -0
  196. {letify-2.7.1 → letify-2.8.0}/tests/test_kaggle.py +0 -0
  197. {letify-2.7.1 → letify-2.8.0}/tests/test_kaggle_cookie.py +0 -0
  198. {letify-2.7.1 → letify-2.8.0}/tests/test_kaggle_link.py +0 -0
  199. {letify-2.7.1 → letify-2.8.0}/tests/test_link_log.py +0 -0
  200. {letify-2.7.1 → letify-2.8.0}/tests/test_notebook.py +0 -0
  201. {letify-2.7.1 → letify-2.8.0}/tests/test_parallel_placement.py +0 -0
  202. {letify-2.7.1 → letify-2.8.0}/tests/test_paths.py +0 -0
  203. {letify-2.7.1 → letify-2.8.0}/tests/test_portmap.py +0 -0
  204. {letify-2.7.1 → letify-2.8.0}/tests/test_protocol.py +0 -0
  205. {letify-2.7.1 → letify-2.8.0}/tests/test_quic.py +0 -0
  206. {letify-2.7.1 → letify-2.8.0}/tests/test_remoting.py +0 -0
  207. {letify-2.7.1 → letify-2.8.0}/tests/test_render.py +0 -0
  208. {letify-2.7.1 → letify-2.8.0}/tests/test_ship.py +0 -0
  209. {letify-2.7.1 → letify-2.8.0}/tests/test_store.py +0 -0
  210. {letify-2.7.1 → letify-2.8.0}/tests/test_strategies.py +0 -0
  211. {letify-2.7.1 → letify-2.8.0}/tests/test_stubs.py +0 -0
  212. {letify-2.7.1 → letify-2.8.0}/tests/test_transport.py +0 -0
  213. {letify-2.7.1 → letify-2.8.0}/tests/test_tunnel_setup.py +0 -0
  214. {letify-2.7.1 → letify-2.8.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.7.1
3
+ Version: 2.8.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
@@ -190,6 +190,15 @@ cache.fetch_checkpoint("run-1", target)
190
190
  cache.resume(runtime, "run-1", path) # put the newest one inside a session
191
191
  ```
192
192
 
193
+ ### Handoffs
194
+
195
+ ```python
196
+ letify.stash("ckpt", ckpt_dir) # put a file or directory on the machine's Drive store
197
+ letify.unstash("ckpt", ckpt_dir) # put it back, or None when no handoff has that name
198
+ ```
199
+
200
+ Both work in the local process and inside a shipped function, on any provider. They need `drive_login` and `drive_project` in the machine configuration and raise `ConfigError` without them. On a one-shot channel, which has no stream back to the local process, they raise `UnsupportedMode` naming the provider. `letify.fetch(tensor)` is a different call and still reads a tensor back.
201
+
193
202
  ### Types
194
203
 
195
204
  | Name | Is |
@@ -200,6 +209,7 @@ cache.resume(runtime, "run-1", path) # put the newest one inside a session
200
209
  | `Env` | an environment declaration |
201
210
  | `Volume` | a content addressed store on a provider |
202
211
  | `session_cache` | a value built once per session, keyed by name |
212
+ | `stash`, `unstash` | a named file or directory handed between providers through Drive |
203
213
  | `Blob`, `RemoteFile` | references to something that lives in a session |
204
214
  | `Function` | what the decorator returns |
205
215
 
@@ -781,6 +781,7 @@ The protocol is one JSON object per line. letify sends `{"id": <int>, "op": <nam
781
781
  | `tunnel` | `sandbox`, `port` | `{"host": <text>, "port": <int>, "tls": <bool>}`. The address that reaches `port` inside the sandbox, from `Sandbox.tunnels()`. `tls` is true when the connection has to be made with TLS, which an encrypted port needs |
782
782
  | `write` | `sandbox`, `data` | `null`. `data` is base64 of at most 1 MiB. Writes the decoded bytes to the sandbox's standard input and drains it. Modal refuses a write that would buffer more than 2 MiB, so the channel splits a larger frame into `write` requests of 1 MiB, in order |
783
783
  | `read_until` | `sandbox`, `prefixes` | `{"lines": [...], "eof": <bool>}`. The sandbox's stdout lines up to and including the first that starts with one of `prefixes`, or every line left when the stream ends |
784
+ | `poll` | `sandbox` | the sandbox's exit code once it has ended, or `null` while it runs. Polls this adapter's own sandbox, or one found with `modal.Sandbox.from_id`. The provider sends it from a second adapter process and closes that process afterwards |
784
785
  | `terminate` | `sandbox` | `null`. Also ends a sandbox this adapter did not create, found by id |
785
786
  | `billing_summary` | none | `{"metered_cost": <text>, "billed_cost": <text>, "credits": <text>, "start": <Unix seconds>, "end": <Unix seconds>}` for the current month, from `modal.Workspace.billing.summary()`. Amounts are decimal text in USD. `credits` is the `Credits` adjustment, negative when credit was applied |
786
787
  | `volume_put` | `volume`, `version`, `path`, `data` | `null`. `data` is base64 |
@@ -987,7 +988,7 @@ A session is never a value the caller holds. Pooling, reuse and teardown are dec
987
988
 
988
989
  1. **The call.** A session ends when the call that started it finishes. Calls that overlap in time share that span: a session released by one call while another is still running stays up for the next call that matches it, and ends when the last overlapping call finishes.
989
990
  2. **A `keep_alive` block.** Inside `with let.keep_alive():` a session is not ended when its call finishes, so the next call in the block that matches its instance and environment reuses it and pays no session start, which is minutes on Colab. When the block exits, every idle session ends; one still serving a call ends when that call finishes. Blocks nest, and only the outermost exit ends anything.
990
- 3. **The lease.** The local process renews a deadline inside the session every 30 seconds, and the worker exits on its own if the deadline passes. The grace period is 300 seconds, so a brief network drop does not kill a training run. A renewal that fails because the session itself is gone, `RuntimeLost`, stops the renewer; the pool notices on its next call. Any other failure, such as a renewal reply delayed behind a large transfer, logs a warning on standard error and is retried on the next interval rather than stopping the renewer. The protocol answers `lease` on the thread that reads frames rather than the one running the current call, so a renewal is not queued behind a checkpoint upload or download of any size; raising the grace period further would only lengthen how long a process that really was killed outright goes on billing, for no corresponding benefit.
991
+ 3. **The lease.** The local process renews a deadline inside the session every 30 seconds, and the worker exits on its own if the deadline passes. The grace period is 300 seconds, so a brief network drop does not kill a training run. A renewal that fails because the session itself is gone, `RuntimeLost`, stops the renewer; the pool notices on its next call. Any other failure, such as a renewal reply delayed behind a large transfer, logs a warning on standard error and is retried on the next interval rather than stopping the renewer. Before that retry, the local process asks the provider whether the runtime still exists. A provider that can tell answers ended, running or unknown; Modal answers by polling the sandbox from a second adapter process, because the adapter carrying the call may be blocked. An ended runtime turns the failure into `RuntimeLost`: the renewer stops, the runtime is discarded with reason `runtime_ended`, and a call waiting on it raises `RuntimeLost` saying `the Modal sandbox ended (exit code <n>) while the call was running`, rather than waiting for a reply that cannot come, because a data connection to a sandbox that has ended can stay open with no end-of-stream. A provider that cannot tell, or a poll that itself fails, answers unknown, and the renewer retries as before. The protocol answers `lease` on the thread that reads frames rather than the one running the current call, so a renewal is not queued behind a checkpoint upload or download of any size; raising the grace period further would only lengthen how long a process that really was killed outright goes on billing, for no corresponding benefit.
991
992
 
992
993
  Keeping is a block rather than a declaration argument because it describes a stretch of the caller's program, not a property of one function: the same function is kept in one script and not in another. A block also has a visible end, so no session outlives the code that asked for it.
993
994
 
@@ -1266,6 +1267,18 @@ The runtime keeps a file blob cache at `<workspace root>/data/blobs/<first two h
1266
1267
 
1267
1268
  `bucket = "<name>"` on an account names a Cloud Storage bucket for its data, reached as the `gcs` backend describes. `bucket_prefix` sets the object prefix, `letify` by default, and `bucket_endpoint` and `sts_endpoint` point the client and the token exchange elsewhere. A persistent account ignores `bucket`.
1268
1269
 
1270
+ Google Drive is a store, not a volume: it does not attach to a runtime as a disk, it belongs to no provider, and every provider that can reach `googleapis.com` over HTTPS reaches the same one. It is declared once per machine with two top-level settings in `~/.letify/config.toml`: `drive_login = "<alias>"` names the account whose Google login letify uses, and `drive_project = "<project id>"` names the Google Cloud project the Drive API bills its quota to, which has to have the Drive API enabled. Both are needed; either alone is refused naming the other. `drive_endpoint` points the client and the runtime at another Drive API host, for a test. The named account's stored token has to carry the `drive.file` scope, which the Colab login already does, so no new login is asked for. `drive.file` lets letify read and write only the files it created, and never the rest of the person's Drive.
1271
+
1272
+ Drive is fast for a few large files and slow for many small ones, which decides what letify puts there. From a Colab runtime and a Kaggle runtime, one 1 GiB file uploaded at 38.1 and 71.6 MiB/s and downloaded at 176.6 and 182.9 MiB/s, and fifty 1 MiB files took 109.3 s and 87.4 s, about two seconds each, because every Drive file is an API call of its own. So letify puts environment archives there, one file per environment, and never project data, which is often many small files and stays on the channel or the bucket.
1273
+
1274
+ Letify keeps its files in a folder named `letify` at the root of that Drive, one file per blob named by its digest, and each ref as a small file named `ref <name>` holding the digest. The runtime moves an archive itself in both directions, so it never crosses the client's link: after a sync the worker uploads the packed archive with a resumable upload, and a later session's worker downloads it. For a download the client mints a short-lived access token from the stored refresh token and sends it with the request; the worker uses it for that request only and does not write it to disk. For an upload the client creates the resumable session itself and sends the worker only the session URL, which authorizes that upload alone, so the worker never holds a token for it. A Drive request that fails is not retried into a slower path: a failed restore makes the session sync and say `letify: <runtime>: not using the Drive store (<reason>); syncing`, and a failed pack, which comes after the sync, leaves nothing cached and says `letify: <runtime>: not using the Drive store (<reason>); not caching the environment`.
1275
+
1276
+ An ephemeral provider uses the Drive store for its environment archive when the machine declares one and the account sets no `bucket`, which keeps precedence. A provider whose environment survives the session on its own disk or volume does not use it. An archive on Drive is an uncompressed tar, because compression was most of the cost: on a Kaggle runtime, packing a 5.5 GB environment as tar.gz and uploading it took about 12 minutes, against an upload of roughly 77 s for the uncompressed tree at the measured rate.
1277
+
1278
+ Whether an archive is worth it is measured, not assumed, because a provider with a fast package index can build faster than it restores. On a Kaggle runtime the same 5.5 GB environment built in 35.4 s and restored from Drive in about 90 s. So after a sync letify packs only when the build took longer than the archive would take to restore, estimated as the project directory's size over 176 MiB/s, the slower Drive download measured, plus the same size over 170 MB/s, the extraction rate measured on a sandbox disk. The record beside the archive keeps the build seconds and, once a session has restored from it, the restore seconds that session measured; a later session restores only while the measured restore is shorter than the measured build, and syncs otherwise, saying `letify: <runtime>: syncing rather than restoring from Drive, because a restore took <s> s against a <s> s build`.
1279
+
1280
+ Drive's own limits apply. The archive counts against the Google account's storage, which is the person's own plan. Google documents a daily upload limit of 750 GB per user for Google Workspace accounts, independent of the storage plan; an environment archive is a few gigabytes, so letify does not approach it. Standard use of the Drive API costs nothing, and Google documents that exceeding its request quota is planned to be charged to the project's billing account later in 2026; letify's requests are a handful per session against a quota of 325,000 units per user per minute.
1281
+
1269
1282
  The file blob cache lives inside the runtime's container, so it disappears when the container does. A later container on the same ephemeral provider can therefore fetch from an object store outside the container or from the local process, and from nothing else, which is why `bucket` exists. The two problems are answered separately: the bucket removes the repeated upload of a dataset the account has already sent once, and the send order with the background sender removes the wait before the first step. Neither replaces the other.
1270
1283
 
1271
1284
  Over the channel, a file travels in pieces of at most 8 MiB, each one a `data_put` request carrying the piece as an out-of-band `DATA` frame, its offset, and whether it is the file's last. Every piece of a call, across all of its files, is sent on one stream in send order, and the local process does not wait for a reply between two pieces: it keeps at most 8 pieces unanswered, which is 64 MiB in flight, as Waiting for a reply describes. So the link carries the next piece while the worker writes the one before, and a small file costs no round trip of its own. The worker appends each piece to `<digest>.partial.<pid>` in arrival order and answers it, and after the last piece of a file checks the digest of the whole file and renames it into the cache. A digest that does not match fails that piece's reply, which raises `RuntimeFailure` on the local side, and nothing is renamed. The local process reads every outstanding reply before it sends the call, so the first wave is placed when the body starts.
@@ -1531,6 +1544,29 @@ The access token is looked up in this order, the first that answers wins:
1531
1544
 
1532
1545
  A token is reused until 60 s before it expires. A service account key file is refused with its reason, because exchanging one needs an RSA signature the standard library cannot make; `gcloud auth activate-service-account` followed by rule 3 covers that case. When no rule answers, the backend raises `ProviderUnavailable` for `gcs` saying that no Google login was found and naming `gcloud auth application-default login` and `GOOGLE_OAUTH_ACCESS_TOKEN`.
1533
1546
 
1547
+ ### Named handoffs on the Drive store <!-- id: drive-handoff -->
1548
+
1549
+ > `letify.stash(name, path)` puts a file or directory on the machine's Drive store under a name, and `letify.unstash(name, path)` puts it back, in a later call on any provider or in the local process.
1550
+
1551
+ A handoff is what a volume cannot be: a value written on one provider and read on another, such as a checkpoint written on Kaggle and resumed on `dept_gpu`. A volume belongs to one provider and stays attached to it, so letify keeps the two apart. `stash` and `unstash` take no provider and no volume.
1552
+
1553
+ `stash(name, path)` packs `path`, a file or a directory, into an uncompressed tar whose members are relative to `path`'s parent, so the tar holds the one entry `path`'s base name. Its digest, letify's content hash as the blob store uses it, names the blob in the `letify` folder, and the ref `ref stash/<name>` holds that digest. It returns the digest once the upload has finished and the ref is written. Stashing a name again moves the ref, and the blob the ref named before is deleted unless another `stash/` ref still names it. An upload of the same digest that Drive already holds sends nothing. The tar keeps each member's modification time, so a file rewritten with the same bytes gives a new digest.
1554
+
1555
+ `unstash(name, path)` reads the ref, downloads the blob, checks its digest, and unpacks it so that the tar's one entry lands at `path`, replacing what `path` held. It returns `path` as a `pathlib.Path`. When no ref `stash/<name>` exists it returns `None` and changes nothing, so `if letify.unstash("ckpt", ckpt_dir) is None:` starts from scratch. A digest that does not match raises `RuntimeFailure` and leaves `path` untouched.
1556
+
1557
+ A name is 1 to 128 characters of letters, digits, `.`, `_` and `-`, and does not start with `.`; anything else raises `ValueError` naming the rule. Names are shared by every project on the machine's Drive store, so a project that wants its own space puts its name in the handoff name.
1558
+
1559
+ The name `unstash` is not `fetch`, because `letify.fetch(tensor)` already reads a tensor back in `host=local`.
1560
+
1561
+ Both run in two places with the same result:
1562
+
1563
+ - **In the local process**, the client moves the bytes itself with the `DriveBackend` of Google Drive is a store, not a volume.
1564
+ - **Inside a shipped function**, the worker moves the bytes between the runtime and Drive and they never cross the client's link. The worker asks the local process for a grant with a one-way request on a reserved stream, as `all_reduce` asks for a peer table, and the local process answers with the light op `drive_grant`: for `stash`, the existing digest or a resumable upload session URL for the archive's size, which the local process creates as Google Drive is a store, not a volume describes; for `unstash`, the blob's media URL with the short-lived request headers, or a reply that the name has no ref. After a `stash` upload the worker reports the Drive file id and the local process writes the ref and deletes the replaced blob. The worker holds no refresh token and writes no header to disk.
1565
+
1566
+ A body run on a one-shot channel, which has no stream back to the local process, cannot ask for a grant, so `stash` and `unstash` there raise `UnsupportedMode` naming the provider rather than reading the runtime's own configuration.
1567
+
1568
+ When the machine declares no Drive store, both raise `ConfigError` naming `drive_login` and `drive_project`. letify never falls back to another store or to the local disk. A Drive request that fails raises `RuntimeFailure` naming the Drive status and the name, and the function body decides whether to carry on.
1569
+
1534
1570
  ## Environment
1535
1571
 
1536
1572
  > An `Env` is a declaration keyed by the project's uv files, not a built image. Every remote runtime builds it with `uv sync` into a project `.venv` and runs the worker with that `.venv`'s Python.
@@ -114,13 +114,18 @@ Chosen by the provider, so your declaration does not mention one.
114
114
 
115
115
  > 🌏 **For Colab, use a multi-region bucket such as `US`.** You cannot choose where a Colab runtime lands, and a multi-region bucket avoids a cross-region charge on every read. Set a billing alert while you are there.
116
116
 
117
- ## Why not Google Drive
117
+ ## Google Drive holds environment archives, not data
118
118
 
119
- Drive looks like a disk when mounted, which is the source of the confusion. It is a consumer file service reached one file at a time, and every file access is an HTTP request of tens to hundreds of milliseconds regardless of the file's size.
119
+ Drive is reached one file at a time, and every file access is an HTTP request of tens to hundreds of milliseconds regardless of the file's size. 50,000 images at 100 ms each is over an hour of pure input and output per epoch, with the GPU idle. So letify never puts project data on Drive.
120
120
 
121
- The arithmetic: 50,000 images at 100 ms each is over an hour of pure input and output per epoch, with the GPU idle at 10 to 20% utilization the whole time. The same data as one archive, copied once and unpacked locally, is under a minute.
121
+ One large file is a different case. Measured from Kaggle, Drive downloads at 183 MiB/s and accepts uploads at 72 MiB/s. letify therefore uses Drive for one thing: the packed environment of an ephemeral provider that has no `bucket`. Declare it once for the machine, at the top of `~/.letify/config.toml`:
122
122
 
123
- Drive is a fine warehouse. It is not a working disk. If you already keep data there, pull one archive at session start rather than reading from it during training.
123
+ ```toml
124
+ drive_login = "colab_pro" # the account whose Google login carries the drive.file scope
125
+ drive_project = "letify-511514" # a Google Cloud project with the Drive API enabled
126
+ ```
127
+
128
+ Either setting alone is refused. A persistent provider ignores both, and an account with `bucket` keeps its bucket. The archive is packed only when the environment took longer to build than it would take to download and unpack, and a later session syncs instead of restoring once a recorded restore has taken longer than the build. Standard Drive API use carries no charge, and the archive counts against your Drive storage.
124
129
 
125
130
  ## Data your call reads
126
131
 
@@ -114,13 +114,18 @@ cache = lab.volume("scratch", backend="filesystem", root="/mnt/data/letify")
114
114
 
115
115
  > 🌏 **Colab에서는 `US` 같은 멀티리전 버킷을 쓰세요.** Colab 런타임이 어디 뜨는지 고를 수 없고, 멀티리전이면 읽을 때마다 리전 간 요금이 붙는 것을 막아줍니다. 그 참에 결제 알림도 걸어두세요.
116
116
 
117
- ## Google Drive를 안 쓰는 이유
117
+ ## Google Drive는 데이터가 아니라 환경 아카이브를 담습니다
118
118
 
119
- 드라이브는 마운트하면 디스크처럼 보이고, 거기서 오해가 생깁니다. 실제로는 파일 단위로 접근하는 소비자용 파일 서비스이고, 파일 크기와 무관하게 접근마다 수십에서 수백 밀리초의 HTTP 요청이 나갑니다.
119
+ 드라이브는 파일 단위로 접근하고, 파일 크기와 무관하게 접근마다 수십에서 수백 밀리초의 HTTP 요청이 나갑니다. 이미지 5만 장에 100ms씩이면 epoch당 순수 입출력만 한 시간이 넘고 그동안 GPU는 놉니다. 그래서 letify는 프로젝트 데이터를 드라이브에 두지 않습니다.
120
120
 
121
- 계산해보면, 이미지 5만 장에 100ms씩이면 epoch당 순수 입출력만 한 시간이 넘고 그동안 GPU 사용률은 10~20%입니다. 같은 데이터를 아카이브 하나로 한 번 받아 로컬에 풀면 1분 미만입니다.
121
+ 큰 파일 하나는 다릅니다. Kaggle에서 재보면 드라이브는 183 MiB/s로 내려받고 72 MiB/s로 올립니다. 그래서 letify는 드라이브를 한 가지에만 씁니다. `bucket`이 없는 휘발성 프로바이더의 묶은 환경입니다. 머신에 한 번, `~/.letify/config.toml` 맨 위에 선언합니다.
122
122
 
123
- 드라이브는 창고로는 괜찮습니다. 작업 디스크가 아닙니다. 이미 거기 데이터가 있다면 학습 중에 읽지 말고 세션 시작에 아카이브 하나를 당겨오세요.
123
+ ```toml
124
+ drive_login = "colab_pro" # drive.file 권한이 있는 Google 로그인을 가진 계정
125
+ drive_project = "letify-511514" # Drive API를 켠 Google Cloud 프로젝트
126
+ ```
127
+
128
+ 둘 중 하나만 있으면 거부합니다. persistent 프로바이더는 둘 다 무시하고, `bucket`이 있는 계정은 버킷을 그대로 씁니다. 아카이브는 환경 빌드가 내려받아 푸는 시간보다 오래 걸렸을 때만 묶고, 기록된 복원 시간이 빌드보다 길었다면 다음 세션은 복원 대신 동기화합니다. 표준 Drive API 사용은 요금이 없고, 아카이브는 드라이브 저장 용량을 차지합니다.
124
129
 
125
130
  ## 호출이 읽는 데이터
126
131
 
@@ -27,6 +27,8 @@ the base install and a provider whose package is missing reports itself unavaila
27
27
 
28
28
  from __future__ import annotations
29
29
 
30
+ import os
31
+ from pathlib import Path
30
32
  from typing import Final
31
33
 
32
34
  from .declare.cache import session_cache
@@ -66,7 +68,7 @@ distributed: Final = Host.distributed
66
68
  # The enum class stays importable from letify.declare.instance for letify's own use.
67
69
  del Host
68
70
 
69
- __version__ = "2.7.1"
71
+ __version__ = "2.8.0"
70
72
 
71
73
 
72
74
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -80,6 +82,27 @@ def fetch(tensor): # type: ignore[no-untyped-def]
80
82
  return queue_fetch(tensor)
81
83
 
82
84
 
85
+ def stash(name: str, path: str | os.PathLike[str]) -> str:
86
+ """Put a file or directory on the machine's Drive store under ``name``.
87
+
88
+ Returns the digest once the upload has finished and the ref is written. A later call on
89
+ any provider, or the local process, gets it back with ``unstash(name, path)``.
90
+ """
91
+ from .store.handoff import stash as stash_handoff
92
+
93
+ return stash_handoff(name, path)
94
+
95
+
96
+ def unstash(name: str, path: str | os.PathLike[str]) -> Path | None:
97
+ """Put the handoff ``name`` at ``path``, replacing what was there.
98
+
99
+ Returns ``path``, or None when no handoff has that name, in which case nothing changed.
100
+ """
101
+ from .store.handoff import unstash as unstash_handoff
102
+
103
+ return unstash_handoff(name, path)
104
+
105
+
83
106
  __all__ = [
84
107
  "AnyInstance",
85
108
  "Blob",
@@ -108,12 +131,15 @@ __all__ = [
108
131
  "__version__",
109
132
  "all_reduce",
110
133
  "distributed",
134
+ "fetch",
111
135
  "local",
112
136
  "local_steps",
113
137
  "mean",
114
138
  "rank",
115
139
  "remote",
116
140
  "session_cache",
141
+ "stash",
117
142
  "sum",
143
+ "unstash",
118
144
  "world_size",
119
145
  ]
@@ -12,6 +12,7 @@ or the credential lives in the account directory, and the value is resolved when
12
12
  from __future__ import annotations
13
13
 
14
14
  import tomllib
15
+ from dataclasses import dataclass
15
16
  from pathlib import Path
16
17
  from typing import Any
17
18
 
@@ -91,6 +92,44 @@ def load(path: str | Path | None = None, *, home: bool = True) -> Config:
91
92
  return config
92
93
 
93
94
 
95
+ @dataclass(frozen=True, slots=True)
96
+ class DriveSettings:
97
+ """The machine's Google Drive store: the login alias, the quota project and the host."""
98
+
99
+ login: str
100
+ project: str
101
+ endpoint: str | None = None
102
+
103
+
104
+ def drive_settings() -> DriveSettings | None:
105
+ """The Drive store this machine declares at the top level of its configuration, if any.
106
+
107
+ Spec "Materializing into a runtime": ``drive_login`` and ``drive_project`` are both
108
+ needed, and either alone is refused naming the other. ``drive_endpoint`` is optional.
109
+ """
110
+ file = config_path()
111
+ try:
112
+ settings = _parse(file) if file.is_file() else {}
113
+ except OSError:
114
+ settings = {}
115
+ login = settings.get("drive_login")
116
+ project = settings.get("drive_project")
117
+ if not login and not project:
118
+ return None
119
+ if not login:
120
+ raise ConfigError(
121
+ f"{config_label()} sets drive_project but not drive_login. Set drive_login = "
122
+ f"'<alias of a Google login>' to keep environment archives in Google Drive."
123
+ )
124
+ if not project:
125
+ raise ConfigError(
126
+ f"{config_label()} sets drive_login but not drive_project. Set drive_project = "
127
+ f"'<Google Cloud project id>' with the Drive API enabled."
128
+ )
129
+ endpoint = settings.get("drive_endpoint")
130
+ return DriveSettings(str(login), str(project), str(endpoint) if endpoint else None)
131
+
132
+
94
133
  def _project_file(path: str | Path | None) -> Path:
95
134
  """The project's ``config.toml``, from a ``.letify`` directory, a file, or the default."""
96
135
  if path is None:
@@ -157,8 +196,10 @@ __all__ = [
157
196
  "GLOBAL_FIELD",
158
197
  "RESERVED_ALIASES",
159
198
  "Config",
199
+ "DriveSettings",
160
200
  "ProviderConfig",
161
201
  "account_directory",
202
+ "drive_settings",
162
203
  "inventory",
163
204
  "load",
164
205
  "login",
@@ -141,6 +141,18 @@ def _hasher() -> Any:
141
141
  return blake3.blake3(max_threads=blake3.blake3.AUTO)
142
142
 
143
143
 
144
+ def new_hasher() -> Any:
145
+ """A fresh incremental hasher, the one ``digest_parts`` uses."""
146
+ return _hasher()
147
+
148
+
149
+ def finish_digest(hasher: Any) -> str:
150
+ """The content address a hasher from ``new_hasher`` has accumulated."""
151
+ if hasher.name == "blake2b":
152
+ return str(hasher.hexdigest())
153
+ return str(hasher.hexdigest(length=16))
154
+
155
+
144
156
  def digest_parts(parts: Iterable[Any]) -> str:
145
157
  """Content address of the concatenation of ``parts``, hashed without joining them.
146
158
 
@@ -284,6 +296,8 @@ __all__ = [
284
296
  "digest_parts",
285
297
  "dumps_call",
286
298
  "dumps_call_parts",
299
+ "finish_digest",
300
+ "new_hasher",
287
301
  "parse",
288
302
  "split_output",
289
303
  "unwrap",
@@ -30,6 +30,7 @@ _NO_LETIFY = (
30
30
  "the project with 'uv add letify' so uv.lock carries it into the runtime."
31
31
  )
32
32
  _MODULES = {modules_code}
33
+ os.environ["LETIFY_ONE_SHOT"] = {provider!r}
33
34
  _WORKSPACE = {workspace_root!r}
34
35
  if _MODULES:
35
36
  _modules_dir = os.path.join(os.path.expanduser(_WORKSPACE), "modules")
@@ -112,6 +113,7 @@ def build(
112
113
  *,
113
114
  modules: Sequence[str] = (),
114
115
  workspace_root: str = "~/.letify-runtime",
116
+ provider: str = "this provider",
115
117
  ) -> str:
116
118
  """Return the script that runs one call and prints its outcome."""
117
119
  payload = base64.b64encode(dumps_call(fn, args, kwargs)).decode()
@@ -135,6 +137,7 @@ def build(
135
137
  version=PROTOCOL_VERSION,
136
138
  modules_code=repr(modules_dict),
137
139
  workspace_root=workspace_root,
140
+ provider=provider,
138
141
  )
139
142
 
140
143
 
@@ -47,6 +47,11 @@ DATA_STREAM = 4
47
47
  #: request-reply carrying op "reduce_peers".
48
48
  REDUCE_STREAM = 6
49
49
 
50
+ #: The stream a function body asks for a Drive grant on, as spec "Named handoffs on the
51
+ #: Drive store" describes. Nothing replies on it either; the client's answer travels as an
52
+ #: ordinary request-reply carrying op "drive_grant".
53
+ DRIVE_STREAM = 8
54
+
50
55
  #: The largest payload of one ``DATA`` frame, so other streams interleave between chunks.
51
56
  CHUNK = 8 << 20
52
57
 
@@ -615,6 +620,7 @@ __all__ = [
615
620
  "CHUNK",
616
621
  "DATA",
617
622
  "DEVICE_STREAM",
623
+ "DRIVE_STREAM",
618
624
  "HEADER",
619
625
  "HELLO",
620
626
  "MAGIC",
@@ -1339,6 +1339,38 @@ def _op_reduce_peers(request):
1339
1339
  return {"ok": True, "value": None}
1340
1340
 
1341
1341
 
1342
+ #: Guards ``_DRIVE_ANSWERS``, and wakes a ``stash`` or ``unstash`` waiting on an answer.
1343
+ _DRIVE_LOCK = threading.Condition()
1344
+
1345
+ #: The client's answers to a Drive request, keyed by request id, until the waiting
1346
+ #: ``stash`` or ``unstash`` takes them.
1347
+ _DRIVE_ANSWERS = {}
1348
+
1349
+
1350
+ def _drive_ask(message):
1351
+ """Ask the client for a Drive grant. Nothing replies to this request.
1352
+
1353
+ Mirrors ``_reduce_join``: a one-way request on a reserved stream, answered separately
1354
+ as an ordinary request-reply, op "drive_grant", below.
1355
+ """
1356
+ try:
1357
+ _SENDER.message(REQUEST, DRIVE_STREAM, message)
1358
+ except OSError:
1359
+ pass
1360
+
1361
+
1362
+ def _op_drive_grant(request):
1363
+ """The client's answer to a Drive request: a grant, a reply that nothing is there, or a failure.
1364
+
1365
+ The body is blocked waiting for this, so it is answered by the frame reader at once
1366
+ rather than queued behind the call, which is why it is in ``_LIGHT`` below.
1367
+ """
1368
+ with _DRIVE_LOCK:
1369
+ _DRIVE_ANSWERS[request["id"]] = request
1370
+ _DRIVE_LOCK.notify_all()
1371
+ return {"ok": True, "value": None}
1372
+
1373
+
1342
1374
  def _data_state_for(path):
1343
1375
  """The running call a path belongs to, or None."""
1344
1376
  for state in _DATA_CALLS.values():
@@ -2200,7 +2232,7 @@ def _unpack(path, target, links=False):
2200
2232
  interpreter by absolute path.
2201
2233
  """
2202
2234
  os.makedirs(target, exist_ok=True)
2203
- with tarfile.open(path, "r:gz") as archive:
2235
+ with tarfile.open(path, "r:*") as archive:
2204
2236
  try:
2205
2237
  archive.extractall(target, filter="tar" if links else "data")
2206
2238
  except TypeError:
@@ -2290,6 +2322,8 @@ def _op_pack_blob(request):
2290
2322
  Same archive and member rules as ``pack_dir``, but the archive never exists as one
2291
2323
  bytes object: it is written to a partial file in pieces, and the caller reads it back
2292
2324
  with ``data_stream``. The reply names the directory it used, with ``~`` expanded.
2325
+ The archive is gzip unless the request sets ``gzip`` to false, which writes a plain
2326
+ tar for a store that moves the archive at a disk's speed.
2293
2327
  """
2294
2328
  root = os.path.expanduser(request["path"])
2295
2329
  directory = os.path.expanduser(request["dir"])
@@ -2301,7 +2335,8 @@ def _op_pack_blob(request):
2301
2335
  try:
2302
2336
  with open(partial, "wb") as handle:
2303
2337
  hashed = _HashedFile(handle, hasher)
2304
- with tarfile.open(fileobj=hashed, mode="w:gz") as archive:
2338
+ mode = "w:gz" if request.get("gzip", True) else "w"
2339
+ with tarfile.open(fileobj=hashed, mode=mode) as archive:
2305
2340
  archive.add(root, arcname=os.path.basename(root.rstrip("/")), recursive=True)
2306
2341
  digest = hasher.hexdigest() if hasher.name == "blake2b" else hasher.hexdigest(length=16)
2307
2342
  final = _data_file(directory, digest)
@@ -2313,6 +2348,48 @@ def _op_pack_blob(request):
2313
2348
  return {"ok": True, "value": {"digest": digest, "size": hashed.size, "dir": directory}}
2314
2349
 
2315
2350
 
2351
+ def _op_push_blob(request):
2352
+ """Upload a blob from the blob cache to a resumable upload session, in pieces.
2353
+
2354
+ The session URL authorizes its own upload, so the request carries no access token.
2355
+ Every piece but the last is a multiple of 256 KiB, as Drive requires, and is answered
2356
+ 308 until the last, which is answered with the new file's metadata. The reply names
2357
+ the file id.
2358
+ """
2359
+ import json
2360
+ import urllib.error
2361
+ import urllib.request
2362
+
2363
+ url = request.pop("url")
2364
+ headers = request.pop("headers", None) or {}
2365
+ path = _data_file(os.path.expanduser(request["dir"]), request["digest"])
2366
+ size = os.path.getsize(path)
2367
+ piece = int(request.get("piece") or (64 << 20))
2368
+ remote_id = None
2369
+ with open(path, "rb") as handle:
2370
+ offset = 0
2371
+ while offset < size:
2372
+ block = handle.read(piece)
2373
+ end = offset + len(block)
2374
+ span = "bytes %d-%d/%d" % (offset, end - 1, size)
2375
+ send = urllib.request.Request(url, data=block, method="PUT", headers=headers)
2376
+ send.add_header("Content-Length", str(len(block)))
2377
+ send.add_header("Content-Range", span)
2378
+ try:
2379
+ with urllib.request.urlopen(send, timeout=3600) as response:
2380
+ remote_id = json.loads(response.read())["id"]
2381
+ except urllib.error.HTTPError as exc:
2382
+ if exc.code != 308:
2383
+ raise RuntimeError(
2384
+ "the upload returned %d: %s"
2385
+ % (exc.code, exc.read()[:300].decode(errors="replace"))
2386
+ )
2387
+ offset = end
2388
+ if remote_id is None:
2389
+ raise RuntimeError("the upload session never answered with a file")
2390
+ return {"ok": True, "value": {"id": remote_id, "size": size}}
2391
+
2392
+
2316
2393
  def _op_place_blob(request):
2317
2394
  """Move a received file blob to its destination path, and unpack it when asked.
2318
2395
 
@@ -2456,6 +2533,7 @@ _OPS = {
2456
2533
  "get_file": _op_get_file,
2457
2534
  "pack_dir": _op_pack_dir,
2458
2535
  "pack_blob": _op_pack_blob,
2536
+ "push_blob": _op_push_blob,
2459
2537
  "place_blob": _op_place_blob,
2460
2538
  "exec": _op_exec,
2461
2539
  "eval": _op_eval,
@@ -2464,12 +2542,13 @@ _OPS = {
2464
2542
  "stat": _op_stat,
2465
2543
  "lease": _op_lease,
2466
2544
  "reduce_peers": _op_reduce_peers,
2545
+ "drive_grant": _op_drive_grant,
2467
2546
  }
2468
2547
 
2469
2548
  #: Answered by the frame reader at once, so they work while a call runs.
2470
2549
  # ``data_failed`` is light because it must not queue behind the call it is about: the call
2471
2550
  # is blocked on a blob the sender will never send, so a queued answer would deadlock.
2472
- _LIGHT = ("stat", "lease", "reduce_peers", "data_failed")
2551
+ _LIGHT = ("stat", "lease", "reduce_peers", "drive_grant", "data_failed")
2473
2552
 
2474
2553
  #: Answered by several replies, the last one marked. These send their own replies.
2475
2554
  _STREAMING = ("data_stream",)
@@ -45,6 +45,18 @@ if TYPE_CHECKING:
45
45
  Persistence = Literal["persistent", "ephemeral"]
46
46
 
47
47
 
48
+ @dataclasses.dataclass(frozen=True)
49
+ class RuntimeState:
50
+ """What a provider knows about whether a runtime still exists.
51
+
52
+ ``status`` is ``"ended"``, ``"running"`` or ``"unknown"``. ``detail`` is the sentence a
53
+ waiting call raises ``RuntimeLost`` with when the status is ``"ended"``.
54
+ """
55
+
56
+ status: Literal["ended", "running", "unknown"] = "unknown"
57
+ detail: str = ""
58
+
59
+
48
60
  class Provider(abc.ABC):
49
61
  """Base class for every provider."""
50
62
 
@@ -502,6 +514,15 @@ class Provider(abc.ABC):
502
514
  """
503
515
  return failure
504
516
 
517
+ def runtime_state(self, runtime: Runtime) -> RuntimeState:
518
+ """Ask the provider whether the runtime still exists, without using its channel.
519
+
520
+ Spec "Lifetime", the lease. The default cannot tell and answers unknown. A provider
521
+ that can tell answers ended, with a sentence saying how it ended, or running. The
522
+ answer must not depend on the channel carrying the call, which may be blocked.
523
+ """
524
+ return RuntimeState()
525
+
505
526
  def stop(self, runtime: Runtime) -> None:
506
527
  """Release whatever the provider allocated for this runtime."""
507
528
  return None
@@ -502,7 +502,7 @@ class Colab(Shell):
502
502
  return self._exec(name, source, timeout)
503
503
 
504
504
  files = ColabFiles(self.alias, name, run, workspace=self.workspace_root)
505
- return OneShotChannel(run, name=name, files=files)
505
+ return OneShotChannel(run, name=name, files=files, provider=self.alias)
506
506
  return super().open_channel(runtime)
507
507
 
508
508
 
@@ -45,7 +45,7 @@ from ..errors import (
45
45
  )
46
46
  from ..protocol import wire
47
47
  from ..runtime.channel import Connection, FramedChannel
48
- from .base import Provider
48
+ from .base import Provider, RuntimeState
49
49
  from .usage import Usage
50
50
 
51
51
  if TYPE_CHECKING:
@@ -105,6 +105,9 @@ WORKER_PACKAGES = ("cloudpickle", "blake3")
105
105
  #: Seconds ``Modal.stop`` and a channel's shutdown frame wait for a busy adapter.
106
106
  STOP_WAIT = 5.0
107
107
 
108
+ #: Seconds the second adapter that polls a sandbox may take before it is killed.
109
+ POLL_LIMIT = 30.0
110
+
108
111
  #: Seconds a sandbox may be idle before Modal terminates it, unless the entry sets
109
112
  #: ``idle_timeout``.
110
113
  IDLE_TIMEOUT = 600
@@ -490,6 +493,33 @@ class Modal(Provider):
490
493
  lifetime_s=self.sandbox_timeout_s,
491
494
  )
492
495
 
496
+ def runtime_state(self, runtime: Runtime) -> RuntimeState:
497
+ """Poll the sandbox from a second adapter process, never the one carrying the call.
498
+
499
+ Spec "Lifetime", the lease. The adapter carrying the call may be blocked in a
500
+ request, so a short-lived one asks. Any failure answers unknown.
501
+ """
502
+ sandbox = self._sandboxes.get(runtime.name)
503
+ if sandbox is None:
504
+ return RuntimeState()
505
+ other = Adapter.for_account(self.alias)
506
+ # A poll that hangs ends the second adapter, which makes the request raise.
507
+ limit = threading.Timer(POLL_LIMIT, other.abort)
508
+ limit.daemon = True
509
+ limit.start()
510
+ try:
511
+ code = other.request("poll", sandbox=sandbox)
512
+ except (LetifyError, ValueError, OSError):
513
+ return RuntimeState()
514
+ finally:
515
+ limit.cancel()
516
+ other.close()
517
+ if code is None:
518
+ return RuntimeState("running")
519
+ return RuntimeState(
520
+ "ended", f"the Modal sandbox ended (exit code {code}) while the call was running"
521
+ )
522
+
493
523
  def stop(self, runtime: Runtime) -> None:
494
524
  sandbox = self._sandboxes.pop(runtime.name, None)
495
525
  if sandbox is None:
@@ -191,6 +191,15 @@ class Adapter:
191
191
  pass
192
192
  return None
193
193
 
194
+ def op_poll(self, request: dict[str, Any]) -> Any:
195
+ """The sandbox's exit code once it ended, or None while it runs."""
196
+ sandbox_id = str(request["sandbox"])
197
+ stream = self.sandboxes.get(sandbox_id)
198
+ # Without a stream it was created by another adapter process, such as a blocked one.
199
+ sandbox = stream.sandbox if stream else load_modal().Sandbox.from_id(sandbox_id)
200
+ code = sandbox.poll()
201
+ return None if code is None else int(code)
202
+
194
203
  # -- billing -----------------------------------------------------------------
195
204
 
196
205
  def op_billing_summary(self, request: dict[str, Any]) -> Any:
@@ -483,6 +483,7 @@ class Shell(Provider):
483
483
  link.runner, # type: ignore[attr-defined]
484
484
  name=runtime.name,
485
485
  files=getattr(link, "files", None),
486
+ provider=self.alias,
486
487
  )
487
488
  return PersistentChannel(
488
489
  link.ssh_command(
@@ -382,7 +382,7 @@ def pull_source(
382
382
  f"if {unpack!r}:\n"
383
383
  f" _target = {target or str(Path(path).parent)!r}\n"
384
384
  " os.makedirs(_target, exist_ok=True)\n"
385
- " with tarfile.open(_path, 'r:gz') as _archive:\n"
385
+ " with tarfile.open(_path, 'r:*') as _archive:\n"
386
386
  " _root = os.path.realpath(_target)\n"
387
387
  " for _member in _archive.getmembers():\n"
388
388
  " _destination = os.path.realpath(os.path.join(_root, _member.name))\n"