letify 2.8.2__tar.gz → 2.8.3__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.8.2 → letify-2.8.3}/PKG-INFO +1 -1
  2. {letify-2.8.2 → letify-2.8.3}/docs/SPEC.md +2 -2
  3. {letify-2.8.2 → letify-2.8.3}/letify/__init__.py +1 -1
  4. {letify-2.8.2 → letify-2.8.3}/letify/providers/colab.py +39 -5
  5. {letify-2.8.2 → letify-2.8.3}/letify/runtime/bootstrap.py +4 -2
  6. {letify-2.8.2 → letify-2.8.3}/letify/runtime/session.py +10 -1
  7. {letify-2.8.2 → letify-2.8.3}/letify-core/Cargo.lock +3 -3
  8. {letify-2.8.2 → letify-2.8.3}/letify-core/Cargo.toml +1 -1
  9. {letify-2.8.2 → letify-2.8.3}/letify-ext/package-lock.json +2 -2
  10. {letify-2.8.2 → letify-2.8.3}/letify-ext/package.json +1 -1
  11. {letify-2.8.2 → letify-2.8.3}/pyproject.toml +1 -1
  12. {letify-2.8.2 → letify-2.8.3}/tests/conftest.py +1 -1
  13. {letify-2.8.2 → letify-2.8.3}/tests/test_drive_store.py +26 -0
  14. {letify-2.8.2 → letify-2.8.3}/tests/test_providers.py +72 -3
  15. {letify-2.8.2 → letify-2.8.3}/tests/test_runtime.py +75 -0
  16. {letify-2.8.2 → letify-2.8.3}/.github/workflows/ci.yml +0 -0
  17. {letify-2.8.2 → letify-2.8.3}/.github/workflows/docs.yml +0 -0
  18. {letify-2.8.2 → letify-2.8.3}/.github/workflows/publish.yml +0 -0
  19. {letify-2.8.2 → letify-2.8.3}/.gitignore +0 -0
  20. {letify-2.8.2 → letify-2.8.3}/.researchtree +0 -0
  21. {letify-2.8.2 → letify-2.8.3}/CLAUDE.md +0 -0
  22. {letify-2.8.2 → letify-2.8.3}/LICENSE +0 -0
  23. {letify-2.8.2 → letify-2.8.3}/PROJECT.md +0 -0
  24. {letify-2.8.2 → letify-2.8.3}/README.md +0 -0
  25. {letify-2.8.2 → letify-2.8.3}/docs/COMPONENT.md +0 -0
  26. {letify-2.8.2 → letify-2.8.3}/docs/INTENT.md +0 -0
  27. {letify-2.8.2 → letify-2.8.3}/docs/NETWORK.md +0 -0
  28. {letify-2.8.2 → letify-2.8.3}/docs/guide/01-getting-started.md +0 -0
  29. {letify-2.8.2 → letify-2.8.3}/docs/guide/02-providers.md +0 -0
  30. {letify-2.8.2 → letify-2.8.3}/docs/guide/03-execution-modes.md +0 -0
  31. {letify-2.8.2 → letify-2.8.3}/docs/guide/04-environments-and-data.md +0 -0
  32. {letify-2.8.2 → letify-2.8.3}/docs/guide/05-concurrency.md +0 -0
  33. {letify-2.8.2 → letify-2.8.3}/docs/guide/06-cost.md +0 -0
  34. {letify-2.8.2 → letify-2.8.3}/docs/guide/07-troubleshooting.md +0 -0
  35. {letify-2.8.2 → letify-2.8.3}/docs/guide/08-releasing.md +0 -0
  36. {letify-2.8.2 → letify-2.8.3}/docs/guide/README.md +0 -0
  37. {letify-2.8.2 → letify-2.8.3}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  38. {letify-2.8.2 → letify-2.8.3}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  39. {letify-2.8.2 → letify-2.8.3}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  40. {letify-2.8.2 → letify-2.8.3}/docs/guide/ko/04-/355/231/230/352/262/275/352/263/274-/353/215/260/354/235/264/355/204/260.md" +0 -0
  41. {letify-2.8.2 → letify-2.8.3}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  42. {letify-2.8.2 → letify-2.8.3}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  43. {letify-2.8.2 → letify-2.8.3}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  44. {letify-2.8.2 → letify-2.8.3}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  45. {letify-2.8.2 → letify-2.8.3}/docs/guide/ko/README.md +0 -0
  46. {letify-2.8.2 → letify-2.8.3}/docs/locales/README_ko.md +0 -0
  47. {letify-2.8.2 → letify-2.8.3}/docs/mkdocs.yml +0 -0
  48. {letify-2.8.2 → letify-2.8.3}/examples/README.md +0 -0
  49. {letify-2.8.2 → letify-2.8.3}/examples/nvfp4-lora/.letify.example +0 -0
  50. {letify-2.8.2 → letify-2.8.3}/examples/nvfp4-lora/00_smoke.py +0 -0
  51. {letify-2.8.2 → letify-2.8.3}/examples/nvfp4-lora/01_sweep.py +0 -0
  52. {letify-2.8.2 → letify-2.8.3}/examples/nvfp4-lora/02_resume.py +0 -0
  53. {letify-2.8.2 → letify-2.8.3}/examples/nvfp4-lora/03_watch.py +0 -0
  54. {letify-2.8.2 → letify-2.8.3}/examples/nvfp4-lora/README.md +0 -0
  55. {letify-2.8.2 → letify-2.8.3}/examples/nvfp4-lora/README_ko.md +0 -0
  56. {letify-2.8.2 → letify-2.8.3}/examples/nvfp4-lora/common.py +0 -0
  57. {letify-2.8.2 → letify-2.8.3}/examples/nvfp4-lora/recipe.py +0 -0
  58. {letify-2.8.2 → letify-2.8.3}/letify/cli.py +0 -0
  59. {letify-2.8.2 → letify-2.8.3}/letify/config/__init__.py +0 -0
  60. {letify-2.8.2 → letify-2.8.3}/letify/config/inventory.py +0 -0
  61. {letify-2.8.2 → letify-2.8.3}/letify/config/login.py +0 -0
  62. {letify-2.8.2 → letify-2.8.3}/letify/config/schema.py +0 -0
  63. {letify-2.8.2 → letify-2.8.3}/letify/config/secrets.py +0 -0
  64. {letify-2.8.2 → letify-2.8.3}/letify/config/writer.py +0 -0
  65. {letify-2.8.2 → letify-2.8.3}/letify/declare/__init__.py +0 -0
  66. {letify-2.8.2 → letify-2.8.3}/letify/declare/cache.py +0 -0
  67. {letify-2.8.2 → letify-2.8.3}/letify/declare/env.py +0 -0
  68. {letify-2.8.2 → letify-2.8.3}/letify/declare/function.py +0 -0
  69. {letify-2.8.2 → letify-2.8.3}/letify/declare/instance.py +0 -0
  70. {letify-2.8.2 → letify-2.8.3}/letify/declare/reduce.py +0 -0
  71. {letify-2.8.2 → letify-2.8.3}/letify/declare/replicas.py +0 -0
  72. {letify-2.8.2 → letify-2.8.3}/letify/errors.py +0 -0
  73. {letify-2.8.2 → letify-2.8.3}/letify/install.py +0 -0
  74. {letify-2.8.2 → letify-2.8.3}/letify/launcher.py +0 -0
  75. {letify-2.8.2 → letify-2.8.3}/letify/paths.py +0 -0
  76. {letify-2.8.2 → letify-2.8.3}/letify/protocol/__init__.py +0 -0
  77. {letify-2.8.2 → letify-2.8.3}/letify/protocol/codec.py +0 -0
  78. {letify-2.8.2 → letify-2.8.3}/letify/protocol/driver.py +0 -0
  79. {letify-2.8.2 → letify-2.8.3}/letify/protocol/handle.py +0 -0
  80. {letify-2.8.2 → letify-2.8.3}/letify/protocol/wire.py +0 -0
  81. {letify-2.8.2 → letify-2.8.3}/letify/protocol/worker.py +0 -0
  82. {letify-2.8.2 → letify-2.8.3}/letify/providers/__init__.py +0 -0
  83. {letify-2.8.2 → letify-2.8.3}/letify/providers/base.py +0 -0
  84. {letify-2.8.2 → letify-2.8.3}/letify/providers/colab_files.py +0 -0
  85. {letify-2.8.2 → letify-2.8.3}/letify/providers/colab_keepalive.py +0 -0
  86. {letify-2.8.2 → letify-2.8.3}/letify/providers/elice.py +0 -0
  87. {letify-2.8.2 → letify-2.8.3}/letify/providers/kaggle.py +0 -0
  88. {letify-2.8.2 → letify-2.8.3}/letify/providers/kaggle_adapter.py +0 -0
  89. {letify-2.8.2 → letify-2.8.3}/letify/providers/local.py +0 -0
  90. {letify-2.8.2 → letify-2.8.3}/letify/providers/modal.py +0 -0
  91. {letify-2.8.2 → letify-2.8.3}/letify/providers/modal_adapter.py +0 -0
  92. {letify-2.8.2 → letify-2.8.3}/letify/providers/naming.py +0 -0
  93. {letify-2.8.2 → letify-2.8.3}/letify/providers/shell.py +0 -0
  94. {letify-2.8.2 → letify-2.8.3}/letify/providers/tunnel.py +0 -0
  95. {letify-2.8.2 → letify-2.8.3}/letify/providers/usage.py +0 -0
  96. {letify-2.8.2 → letify-2.8.3}/letify/remoting/__init__.py +0 -0
  97. {letify-2.8.2 → letify-2.8.3}/letify/remoting/capability.py +0 -0
  98. {letify-2.8.2 → letify-2.8.3}/letify/remoting/device/__init__.py +0 -0
  99. {letify-2.8.2 → letify-2.8.3}/letify/remoting/device/client.py +0 -0
  100. {letify-2.8.2 → letify-2.8.3}/letify/remoting/device/cuda.py +0 -0
  101. {letify-2.8.2 → letify-2.8.3}/letify/remoting/device/executor.py +0 -0
  102. {letify-2.8.2 → letify-2.8.3}/letify/remoting/device/frames.py +0 -0
  103. {letify-2.8.2 → letify-2.8.3}/letify/remoting/device/guard.py +0 -0
  104. {letify-2.8.2 → letify-2.8.3}/letify/remoting/device/tensor.py +0 -0
  105. {letify-2.8.2 → letify-2.8.3}/letify/remoting/device/trace.py +0 -0
  106. {letify-2.8.2 → letify-2.8.3}/letify/remoting/device/value.py +0 -0
  107. {letify-2.8.2 → letify-2.8.3}/letify/remoting/loader.py +0 -0
  108. {letify-2.8.2 → letify-2.8.3}/letify/remoting/probe.py +0 -0
  109. {letify-2.8.2 → letify-2.8.3}/letify/render.py +0 -0
  110. {letify-2.8.2 → letify-2.8.3}/letify/runtime/__init__.py +0 -0
  111. {letify-2.8.2 → letify-2.8.3}/letify/runtime/broker.py +0 -0
  112. {letify-2.8.2 → letify-2.8.3}/letify/runtime/channel.py +0 -0
  113. {letify-2.8.2 → letify-2.8.3}/letify/runtime/lease.py +0 -0
  114. {letify-2.8.2 → letify-2.8.3}/letify/runtime/pool.py +0 -0
  115. {letify-2.8.2 → letify-2.8.3}/letify/runtime/telemetry.py +0 -0
  116. {letify-2.8.2 → letify-2.8.3}/letify/store/__init__.py +0 -0
  117. {letify-2.8.2 → letify-2.8.3}/letify/store/backends/__init__.py +0 -0
  118. {letify-2.8.2 → letify-2.8.3}/letify/store/backends/drive.py +0 -0
  119. {letify-2.8.2 → letify-2.8.3}/letify/store/backends/filesystem.py +0 -0
  120. {letify-2.8.2 → letify-2.8.3}/letify/store/backends/google_auth.py +0 -0
  121. {letify-2.8.2 → letify-2.8.3}/letify/store/backends/layout.py +0 -0
  122. {letify-2.8.2 → letify-2.8.3}/letify/store/backends/objects.py +0 -0
  123. {letify-2.8.2 → letify-2.8.3}/letify/store/cas.py +0 -0
  124. {letify-2.8.2 → letify-2.8.3}/letify/store/handoff.py +0 -0
  125. {letify-2.8.2 → letify-2.8.3}/letify/store/pathdata.py +0 -0
  126. {letify-2.8.2 → letify-2.8.3}/letify/store/sendorder.py +0 -0
  127. {letify-2.8.2 → letify-2.8.3}/letify/store/volume.py +0 -0
  128. {letify-2.8.2 → letify-2.8.3}/letify/stubs.py +0 -0
  129. {letify-2.8.2 → letify-2.8.3}/letify/tools.py +0 -0
  130. {letify-2.8.2 → letify-2.8.3}/letify/transport/__init__.py +0 -0
  131. {letify-2.8.2 → letify-2.8.3}/letify/transport/agent.py +0 -0
  132. {letify-2.8.2 → letify-2.8.3}/letify/transport/announce.py +0 -0
  133. {letify-2.8.2 → letify-2.8.3}/letify/transport/link.py +0 -0
  134. {letify-2.8.2 → letify-2.8.3}/letify/transport/nat.py +0 -0
  135. {letify-2.8.2 → letify-2.8.3}/letify/transport/pipeline.py +0 -0
  136. {letify-2.8.2 → letify-2.8.3}/letify/transport/portmap.py +0 -0
  137. {letify-2.8.2 → letify-2.8.3}/letify/transport/probe.py +0 -0
  138. {letify-2.8.2 → letify-2.8.3}/letify/transport/quic.py +0 -0
  139. {letify-2.8.2 → letify-2.8.3}/letify/transport/rendezvous.py +0 -0
  140. {letify-2.8.2 → letify-2.8.3}/letify/transport/setup.py +0 -0
  141. {letify-2.8.2 → letify-2.8.3}/letify/transport/sshopts.py +0 -0
  142. {letify-2.8.2 → letify-2.8.3}/letify/transport/strategies.py +0 -0
  143. {letify-2.8.2 → letify-2.8.3}/letify-core/.gitignore +0 -0
  144. {letify-2.8.2 → letify-2.8.3}/letify-core/build.py +0 -0
  145. {letify-2.8.2 → letify-2.8.3}/letify-core/letify-agent/Cargo.toml +0 -0
  146. {letify-2.8.2 → letify-2.8.3}/letify-core/letify-agent/src/driver.rs +0 -0
  147. {letify-2.8.2 → letify-2.8.3}/letify-core/letify-agent/src/main.rs +0 -0
  148. {letify-2.8.2 → letify-2.8.3}/letify-core/letify-driver/Cargo.toml +0 -0
  149. {letify-2.8.2 → letify-2.8.3}/letify-core/letify-driver/src/client.rs +0 -0
  150. {letify-2.8.2 → letify-2.8.3}/letify-core/letify-driver/src/exports.rs +0 -0
  151. {letify-2.8.2 → letify-2.8.3}/letify-core/letify-driver/src/image.rs +0 -0
  152. {letify-2.8.2 → letify-2.8.3}/letify-core/letify-driver/src/lib.rs +0 -0
  153. {letify-2.8.2 → letify-2.8.3}/letify-core/letify-driver/src/status.rs +0 -0
  154. {letify-2.8.2 → letify-2.8.3}/letify-core/letify-driver/src/table.rs +0 -0
  155. {letify-2.8.2 → letify-2.8.3}/letify-core/letify-quic/Cargo.toml +0 -0
  156. {letify-2.8.2 → letify-2.8.3}/letify-core/letify-quic/src/main.rs +0 -0
  157. {letify-2.8.2 → letify-2.8.3}/letify-core/letify-wire/Cargo.toml +0 -0
  158. {letify-2.8.2 → letify-2.8.3}/letify-core/letify-wire/src/lib.rs +0 -0
  159. {letify-2.8.2 → letify-2.8.3}/letify-core/letify-wire/tests/throughput.rs +0 -0
  160. {letify-2.8.2 → letify-2.8.3}/letify-core/rust-toolchain.toml +0 -0
  161. {letify-2.8.2 → letify-2.8.3}/letify-ext/.vscodeignore +0 -0
  162. {letify-2.8.2 → letify-2.8.3}/letify-ext/LICENSE +0 -0
  163. {letify-2.8.2 → letify-2.8.3}/letify-ext/README.md +0 -0
  164. {letify-2.8.2 → letify-2.8.3}/letify-ext/media/icon.svg +0 -0
  165. {letify-2.8.2 → letify-2.8.3}/letify-ext/src/cli.ts +0 -0
  166. {letify-2.8.2 → letify-2.8.3}/letify-ext/src/extension.ts +0 -0
  167. {letify-2.8.2 → letify-2.8.3}/letify-ext/src/history.ts +0 -0
  168. {letify-2.8.2 → letify-2.8.3}/letify-ext/src/model.ts +0 -0
  169. {letify-2.8.2 → letify-2.8.3}/letify-ext/src/poll.ts +0 -0
  170. {letify-2.8.2 → letify-2.8.3}/letify-ext/src/render.ts +0 -0
  171. {letify-2.8.2 → letify-2.8.3}/letify-ext/test/cli.test.ts +0 -0
  172. {letify-2.8.2 → letify-2.8.3}/letify-ext/test/manifest.test.ts +0 -0
  173. {letify-2.8.2 → letify-2.8.3}/letify-ext/test/model.test.ts +0 -0
  174. {letify-2.8.2 → letify-2.8.3}/letify-ext/test/regressions.test.ts +0 -0
  175. {letify-2.8.2 → letify-2.8.3}/letify-ext/tsconfig.json +0 -0
  176. {letify-2.8.2 → letify-2.8.3}/letify_providers.py +0 -0
  177. {letify-2.8.2 → letify-2.8.3}/scripts/sync-main.sh +0 -0
  178. {letify-2.8.2 → letify-2.8.3}/scripts/version.py +0 -0
  179. {letify-2.8.2 → letify-2.8.3}/tests/fake_kaggle_adapter.py +0 -0
  180. {letify-2.8.2 → letify-2.8.3}/tests/fake_modal_adapter.py +0 -0
  181. {letify-2.8.2 → letify-2.8.3}/tests/test_cli.py +0 -0
  182. {letify-2.8.2 → letify-2.8.3}/tests/test_cli_output.py +0 -0
  183. {letify-2.8.2 → letify-2.8.3}/tests/test_colab_keepalive.py +0 -0
  184. {letify-2.8.2 → letify-2.8.3}/tests/test_config.py +0 -0
  185. {letify-2.8.2 → letify-2.8.3}/tests/test_core.py +0 -0
  186. {letify-2.8.2 → letify-2.8.3}/tests/test_data.py +0 -0
  187. {letify-2.8.2 → letify-2.8.3}/tests/test_data_streaming.py +0 -0
  188. {letify-2.8.2 → letify-2.8.3}/tests/test_declare.py +0 -0
  189. {letify-2.8.2 → letify-2.8.3}/tests/test_device.py +0 -0
  190. {letify-2.8.2 → letify-2.8.3}/tests/test_distributed.py +0 -0
  191. {letify-2.8.2 → letify-2.8.3}/tests/test_drive_handoff.py +0 -0
  192. {letify-2.8.2 → letify-2.8.3}/tests/test_editor_sessions.py +0 -0
  193. {letify-2.8.2 → letify-2.8.3}/tests/test_elice.py +0 -0
  194. {letify-2.8.2 → letify-2.8.3}/tests/test_frames.py +0 -0
  195. {letify-2.8.2 → letify-2.8.3}/tests/test_install.py +0 -0
  196. {letify-2.8.2 → letify-2.8.3}/tests/test_kaggle.py +0 -0
  197. {letify-2.8.2 → letify-2.8.3}/tests/test_kaggle_cookie.py +0 -0
  198. {letify-2.8.2 → letify-2.8.3}/tests/test_kaggle_link.py +0 -0
  199. {letify-2.8.2 → letify-2.8.3}/tests/test_link_log.py +0 -0
  200. {letify-2.8.2 → letify-2.8.3}/tests/test_notebook.py +0 -0
  201. {letify-2.8.2 → letify-2.8.3}/tests/test_parallel_placement.py +0 -0
  202. {letify-2.8.2 → letify-2.8.3}/tests/test_paths.py +0 -0
  203. {letify-2.8.2 → letify-2.8.3}/tests/test_portmap.py +0 -0
  204. {letify-2.8.2 → letify-2.8.3}/tests/test_protocol.py +0 -0
  205. {letify-2.8.2 → letify-2.8.3}/tests/test_quic.py +0 -0
  206. {letify-2.8.2 → letify-2.8.3}/tests/test_remoting.py +0 -0
  207. {letify-2.8.2 → letify-2.8.3}/tests/test_render.py +0 -0
  208. {letify-2.8.2 → letify-2.8.3}/tests/test_ship.py +0 -0
  209. {letify-2.8.2 → letify-2.8.3}/tests/test_store.py +0 -0
  210. {letify-2.8.2 → letify-2.8.3}/tests/test_strategies.py +0 -0
  211. {letify-2.8.2 → letify-2.8.3}/tests/test_stubs.py +0 -0
  212. {letify-2.8.2 → letify-2.8.3}/tests/test_transport.py +0 -0
  213. {letify-2.8.2 → letify-2.8.3}/tests/test_tunnel_setup.py +0 -0
  214. {letify-2.8.2 → letify-2.8.3}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.8.2
3
+ Version: 2.8.3
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
@@ -1162,7 +1162,7 @@ The archive is `<workspace root>/env-archives/<env key>-<platform>.tar`, uncompr
1162
1162
 
1163
1163
  The client's inbound link is the weak one, so the store path is slower than the build in both directions. Compression costs more than it saves on the volume, so the archive is not compressed.
1164
1164
 
1165
- After a successful sync and interpreter check, the runtime says `caching the environment on the workspace root, <size>`, runs tar from the project directory's parent into a temporary name beside the destination, and renames it into place, so a concurrent session never reads a half written archive. Nothing is sent to the client, and no measured rate gates the pack, because the transfer stays inside the provider.
1165
+ After a successful sync and interpreter check, the runtime says `caching the environment on the workspace root, <size>`, runs tar from the project directory's parent into a temporary name beside the destination, and renames it into place, so a concurrent session never reads a half written archive. The temporary name carries 16 random hex digits, `<archive>.<hex>.partial`, not the process id, because every Modal sandbox runs its worker under the same small pid, so two sessions packing the same environment at once wrote one file and one of them failed with tar exit status 2. A pack that fails for any reason removes its temporary file, says `letify: <runtime>: not caching the environment on the workspace root (<reason>)`, and the session goes on: the environment is already built and checked, so a failed cache never fails the session or its call. Nothing is sent to the client, and no measured rate gates the pack, because the transfer stays inside the provider.
1166
1166
 
1167
1167
  At the start of `install_env`, when that archive exists on the runtime, the runtime says `restoring the environment from the workspace root`, extracts it into the project directory's parent, and validates the interpreter with the same check an archive restore uses. The restore refuses a member whose path escapes the destination and allows the absolute symlinks a `.venv` needs. A directory an earlier session left under the same name is removed first. When validation fails, the runtime removes the extracted tree, syncs, and replaces the archive. `Runtime.env_source` is `"workspace"` after this restore. This path is taken before and instead of the store path, and such a provider never uses the client store or the bucket store for its environment.
1168
1168
 
@@ -1931,7 +1931,7 @@ A jump host that letify cannot log in to, or whose `authorized_keys` it cannot e
1931
1931
 
1932
1932
  > Colab is a `Shell` whose rendezvous is `colab exec`. It has no forward SSH, and its fallback is `colab exec` with the Colab file API.
1933
1933
 
1934
- The Colab CLI runs as `uv tool run --from google-colab-cli colab`, with `jupyter-kernel-client<1` pinned, because release 0.6.0 of the CLI calls an API that jupyter-kernel-client 1.0 removed. `colab new` and `colab stop` manage the session. `Colab.sessions()` reads `colab sessions` and returns the first word of each listing line. The CLI prints a session as `[<name>] <id> | Hardware: <hardware> | Variant: <variant>`, so a first word in square brackets gives the name inside them. A line starting with `[colab]` is a message from the CLI, such as `[colab] No active sessions found on server.`, and names no session, so an account with no session returns an empty list.
1934
+ The Colab CLI runs as `uv tool run --from google-colab-cli colab`, with `jupyter-kernel-client<1` pinned, because release 0.6.0 of the CLI calls an API that jupyter-kernel-client 1.0 removed. `colab new` and `colab stop` manage the session. `Colab.sessions()` reads `colab sessions` and returns the first word of each listing line. The CLI prints a session as `[<name>] <id> | Hardware: <hardware> | Variant: <variant>`, so a first word in square brackets gives the name inside them. A line starting with `[colab]` is a message from the CLI, such as `[colab] No active sessions found on server.`, and names no session, so an account with no session returns an empty list. `colab new` runs through the CLI's own Python with a request timeout of 600 s instead of google-auth's 120 s. google-auth binds `google.auth.transport.requests._DEFAULT_TIMEOUT` into `AuthorizedSession.request`'s signature when the module loads, so setting the module value alone changes nothing: the script sets it to 600 and wraps `AuthorizedSession.request` to default `timeout` to it, then calls the CLI's entry point `colab_cli.cli:main`. That is needed because the assignment request for an A100 can take longer than 120 s to answer and the CLI then fails with `ReadTimeout` while the server goes on assigning the machine. letify's own limit on `colab new` stays 900 s.
1935
1935
 
1936
1936
  **An assignment with no session.** `colab new` can fail after Colab has already assigned a machine, for example with `ReadTimeout` after 120 s, and the machine then bills (14.2 compute units per hour for an A100) with no local session state. `colab sessions` lists it as `[?] <endpoint> | ...`, and `colab stop -s <name>` cannot reach it. `Colab.create_session` therefore lists the account's assignments before `colab new`. When `colab new` raises, for any reason, it lists them again, releases every `?` assignment whose endpoint was not in the first list, and re-raises the original error. An assignment that existed before the attempt is never released. Each release prints one line on standard error naming the endpoint. When the first or the second listing fails, one line on standard error names the endpoint check and the error, and the original error is still the one raised. An assignment is released by endpoint with `Client.unassign` of the Colab CLI, run as `uv tool run --from google-colab-cli python -c <script> <endpoint>` with the account's environment, because the CLI has no command for it. `Colab.orphans()` returns the endpoints of the `?` rows of `colab sessions` and `Colab.stop_orphan(endpoint)` releases one, so `letify stop --orphans` covers Colab as it covers Kaggle.
1937
1937
 
@@ -68,7 +68,7 @@ distributed: Final = Host.distributed
68
68
  # The enum class stays importable from letify.declare.instance for letify's own use.
69
69
  del Host
70
70
 
71
- __version__ = "2.8.2"
71
+ __version__ = "2.8.3"
72
72
 
73
73
 
74
74
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -131,6 +131,26 @@ UNASSIGN_SCRIPT = (
131
131
  )
132
132
 
133
133
 
134
+ #: Run in the CLI's own Python: ``colab`` with google-auth's request timeout raised to 600 s.
135
+ #: The CLI passes no timeout, so a slow A100 assignment fails at the 120 s default while the
136
+ #: server goes on assigning. google-auth binds the 120 s default into the signature of
137
+ #: ``AuthorizedSession.request`` when it is defined, so setting the module value alone changes
138
+ #: nothing and the method is wrapped as well. Spec "Colab", the Colab CLI paragraph.
139
+ NEW_SCRIPT = (
140
+ "import sys\n"
141
+ "import google.auth.transport.requests as requests\n"
142
+ "requests._DEFAULT_TIMEOUT = 600\n"
143
+ "_request = requests.AuthorizedSession.request\n"
144
+ "def _slow_request(self, *args, **kwargs):\n"
145
+ " kwargs.setdefault('timeout', requests._DEFAULT_TIMEOUT)\n"
146
+ " return _request(self, *args, **kwargs)\n"
147
+ "requests.AuthorizedSession.request = _slow_request\n"
148
+ "from colab_cli.cli import main\n"
149
+ "sys.argv = ['colab', *sys.argv[1:]]\n"
150
+ "sys.exit(main())\n"
151
+ )
152
+
153
+
134
154
  def _parse_assignments(listing: str) -> list[tuple[str, str]]:
135
155
  """The ``(name, endpoint)`` pairs in ``colab sessions`` output, ``?`` for no local state."""
136
156
  found = []
@@ -275,8 +295,21 @@ class Colab(Shell):
275
295
 
276
296
  # -- the CLI -------------------------------------------------------------
277
297
 
278
- def _cli(self, *args: str, timeout: float | None = None, stdin: str | None = None) -> str:
279
- command = [*self._colab(), *args]
298
+ def _cli(
299
+ self,
300
+ *args: str,
301
+ timeout: float | None = None,
302
+ stdin: str | None = None,
303
+ script: str | None = None,
304
+ ) -> str:
305
+ """Run the CLI; with ``script``, run that code in the CLI's Python with ``args`` as argv."""
306
+ if script is None:
307
+ command = [*self._colab(), *args]
308
+ else:
309
+ uv = tools.find_uv()
310
+ if uv is None:
311
+ raise ProviderUnavailable(self.kind, tools.missing_uv_message())
312
+ command = [*tools.python_command(tools.COLAB, uv), "-c", script, *args]
280
313
  result = subprocess.run(
281
314
  command,
282
315
  input=stdin,
@@ -286,9 +319,10 @@ class Colab(Shell):
286
319
  env=self._env(),
287
320
  )
288
321
  if result.returncode != 0:
322
+ shown = ["<script>" if part == script else part for part in command]
289
323
  raise RuntimeFailure(
290
- f"`{' '.join(command)}` exited {result.returncode}",
291
- command=" ".join(command),
324
+ f"`{' '.join(shown)}` exited {result.returncode}",
325
+ command=" ".join(shown),
292
326
  stderr=(result.stderr or "").strip(),
293
327
  )
294
328
  return result.stdout
@@ -482,7 +516,7 @@ class Colab(Shell):
482
516
  file=sys.stderr,
483
517
  )
484
518
  try:
485
- self._cli(*args, timeout=900)
519
+ self._cli(*args, timeout=900, script=NEW_SCRIPT)
486
520
  except BaseException:
487
521
  # A timed out `colab new` can still leave a server-side assignment behind.
488
522
  if before is not None:
@@ -281,13 +281,15 @@ def workspace_pack_source(root: str, archive: str) -> str:
281
281
  Spec "Volumes on a persistent runtime": runs ``tar`` from the directory's parent into a
282
282
  temporary name and renames it into place, so a concurrent session never reads a half
283
283
  written archive. Uncompressed, because gzip costs more than it saves on a local volume.
284
+ The temporary name carries random hex, not the process id, because every Modal sandbox
285
+ runs its worker under the same pid. A failed pack removes its temporary file.
284
286
  """
285
287
  return (
286
- "import os, subprocess\n"
288
+ "import os, secrets, subprocess\n"
287
289
  f"_root = os.path.expanduser({root!r}).rstrip('/')\n"
288
290
  f"_archive = os.path.expanduser({archive!r})\n"
289
291
  "os.makedirs(os.path.dirname(_archive), exist_ok=True)\n"
290
- "_partial = _archive + '.' + str(os.getpid()) + '.partial'\n"
292
+ "_partial = _archive + '.' + secrets.token_hex(8) + '.partial'\n"
291
293
  "try:\n"
292
294
  " subprocess.run(['tar', '-cf', _partial, '-C', os.path.dirname(_root),\n"
293
295
  " os.path.basename(_root)], check=True, capture_output=True)\n"
@@ -826,7 +826,16 @@ class Runtime:
826
826
  "caching the environment on the workspace root"
827
827
  + (f", {size / 1_000_000_000:.1f} GB" if size else "")
828
828
  )
829
- self.eval(bootstrap.workspace_pack_source(root, self._workspace_archive()), timeout=3600)
829
+ source = bootstrap.workspace_pack_source(root, self._workspace_archive())
830
+ try:
831
+ self.eval(source, timeout=3600)
832
+ except Exception as error:
833
+ # The environment is built and checked, so a failed cache must not fail the call.
834
+ lines = str(error).strip().splitlines()
835
+ reason = f"{type(error).__name__}: {lines[0]}" if lines else type(error).__name__
836
+ if len(reason) > 200:
837
+ reason = reason[:197] + "..."
838
+ self._say(f"not caching the environment on the workspace root ({reason})")
830
839
 
831
840
  def _environment_volumes(self) -> tuple[Volume, ...]:
832
841
  """Explicit archive volumes, or the automatic cache where the project does not survive.
@@ -138,7 +138,7 @@ dependencies = [
138
138
 
139
139
  [[package]]
140
140
  name = "letify-agent"
141
- version = "2.8.2"
141
+ version = "2.8.3"
142
142
  dependencies = [
143
143
  "letify-wire",
144
144
  "libloading",
@@ -146,7 +146,7 @@ dependencies = [
146
146
 
147
147
  [[package]]
148
148
  name = "letify-driver"
149
- version = "2.8.2"
149
+ version = "2.8.3"
150
150
  dependencies = [
151
151
  "letify-wire",
152
152
  ]
@@ -164,7 +164,7 @@ dependencies = [
164
164
 
165
165
  [[package]]
166
166
  name = "letify-wire"
167
- version = "2.8.2"
167
+ version = "2.8.3"
168
168
 
169
169
  [[package]]
170
170
  name = "libc"
@@ -8,7 +8,7 @@ resolver = "3"
8
8
  members = ["letify-wire", "letify-driver", "letify-agent", "letify-quic"]
9
9
 
10
10
  [workspace.package]
11
- version = "2.8.2"
11
+ version = "2.8.3"
12
12
  edition = "2024"
13
13
  license = "MIT"
14
14
  repository = "https://github.com/DarkPyonix/letify"
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "letify-status",
3
- "version": "2.8.2",
3
+ "version": "2.8.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "letify-status",
9
- "version": "2.8.2",
9
+ "version": "2.8.3",
10
10
  "license": "SEE LICENSE IN LICENSE",
11
11
  "devDependencies": {
12
12
  "@types/node": "^18.19.0",
@@ -2,7 +2,7 @@
2
2
  "name": "letify-status",
3
3
  "displayName": "letify Status",
4
4
  "description": "letify quota and GPU activity in the status bar, read from the letify CLI.",
5
- "version": "2.8.2",
5
+ "version": "2.8.3",
6
6
  "publisher": "darkpyonix",
7
7
  "license": "SEE LICENSE IN LICENSE",
8
8
  "repository": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "letify"
3
- version = "2.8.2"
3
+ version = "2.8.3"
4
4
  description = "Declarations that become infrastructure."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -2040,7 +2040,7 @@ class FakeColabCli:
2040
2040
  self.unassigned: list[str] = []
2041
2041
 
2042
2042
  def __call__(self, command):
2043
- if "python" in command and "-c" in command:
2043
+ if "python" in command and "-c" in command and "new" not in command:
2044
2044
  self.unassigned.append(command[-1])
2045
2045
  self.assignments = [a for a in self.assignments if a[1] != command[-1]]
2046
2046
  return FakeCompleted()
@@ -43,6 +43,32 @@ def declare_drive(server: Any, **settings: Any) -> None:
43
43
  paths.config_path().write_text("\n".join(lines) + "\n", encoding="utf-8")
44
44
 
45
45
 
46
+ #: Tests that pin the gate's own rates and arithmetic, so they see the real constants.
47
+ REAL_GATE = {
48
+ "test_the_gate_estimates_a_restore_as_size_over_the_download_and_extraction_rates",
49
+ "test_the_gate_packs_only_when_the_build_took_twice_the_estimated_restore",
50
+ }
51
+
52
+
53
+ @pytest.fixture(autouse=True)
54
+ def pack_whatever_the_build_time(
55
+ request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch
56
+ ) -> None:
57
+ """Make the estimated restore negligible, so a session packs however fast it built.
58
+
59
+ A test session builds in one or two seconds, and whether that clears twice the
60
+ estimated restore of its small tree depends on the machine. A test that needs the
61
+ pack would otherwise pass or fail by timing. Tests in ``REAL_GATE`` and tests that set
62
+ the rates themselves see their own values.
63
+ """
64
+ if request.node.name in REAL_GATE:
65
+ return
66
+ from letify.runtime import session
67
+
68
+ monkeypatch.setattr(session, "DRIVE_DOWNLOAD_BPS", 1e18)
69
+ monkeypatch.setattr(session, "EXTRACT_BPS", 1e18)
70
+
71
+
46
72
  def drive_options(server: Any, **extra: Any) -> dict[str, Any]:
47
73
  """Account options of an ephemeral account, after the machine has declared Drive."""
48
74
  declare_drive(server)
@@ -398,6 +398,9 @@ COLAB_CLI = [
398
398
  ]
399
399
 
400
400
 
401
+ COLAB_PYTHON = [*COLAB_CLI[:-1], "python"]
402
+
403
+
401
404
  def test_a_missing_uv_says_how_to_get_it(isolated_home, patch_which) -> None:
402
405
  # The Colab CLI runs through uv, so uv is the one thing that has to be installed.
403
406
  patch_which(tools_module, present=False)
@@ -414,7 +417,9 @@ def test_creating_a_colab_session_names_the_accelerator_the_cli_accepts(
414
417
  recorder = patch_run(colab_module)
415
418
  provider = provider_of(Colab, "colab_a")
416
419
  provider.create_session(provider.RTX_PRO_6000, "letify-g4-1")
417
- assert recorder.command == [*COLAB_CLI, "new", "-s", "letify-g4-1", "--gpu", "G4"]
420
+ assert recorder.command == [
421
+ *COLAB_PYTHON, "-c", colab_module.NEW_SCRIPT, "new", "-s", "letify-g4-1", "--gpu", "G4"
422
+ ]
418
423
 
419
424
 
420
425
  def test_creating_a_colab_session_for_a_tpu_asks_for_a_tpu(
@@ -424,7 +429,9 @@ def test_creating_a_colab_session_for_a_tpu_asks_for_a_tpu(
424
429
  recorder = patch_run(colab_module)
425
430
  provider = provider_of(Colab, "colab_a")
426
431
  provider.create_session(provider.v5e1, "letify-v5e1-1")
427
- assert recorder.command == [*COLAB_CLI, "new", "-s", "letify-v5e1-1", "--tpu", "v5e1"]
432
+ assert recorder.command == [
433
+ *COLAB_PYTHON, "-c", colab_module.NEW_SCRIPT, "new", "-s", "letify-v5e1-1", "--tpu", "v5e1"
434
+ ]
428
435
 
429
436
 
430
437
  def test_colab_offers_a_cpu_instance_under_the_name_local_uses(isolated_home) -> None:
@@ -441,7 +448,69 @@ def test_creating_a_colab_session_for_the_cpu_asks_for_no_accelerator(
441
448
  recorder = patch_run(colab_module)
442
449
  provider = provider_of(Colab, "colab_a")
443
450
  provider.create_session(provider.cpu, "letify-cpu-1")
444
- assert recorder.command == [*COLAB_CLI, "new", "-s", "letify-cpu-1"]
451
+ assert recorder.command == [
452
+ *COLAB_PYTHON, "-c", colab_module.NEW_SCRIPT, "new", "-s", "letify-cpu-1"
453
+ ]
454
+
455
+
456
+ def test_colab_new_raises_the_request_timeout_to_600_s_and_keeps_the_900_s_limit(
457
+ isolated_home, patch_which, patch_run
458
+ ) -> None:
459
+ # Spec "Colab": google-auth's 120 s default is too short for an A100 assignment.
460
+ patch_which(tools_module, present=True)
461
+ recorder = patch_run(colab_module)
462
+ provider = provider_of(Colab, "colab_a")
463
+ provider.create_session(provider.A100, "letify-a100-1")
464
+ call = next(c for c in recorder.calls if "new" in c["command"])
465
+ assert "_DEFAULT_TIMEOUT = 600" in colab_module.NEW_SCRIPT
466
+ assert "colab_cli.cli" in colab_module.NEW_SCRIPT
467
+ assert call["timeout"] == 900
468
+ assert call["command"][-5:] == ["new", "-s", "letify-a100-1", "--gpu", "A100"]
469
+
470
+
471
+ def test_the_new_script_sets_the_timeout_before_calling_the_cli_and_exits_with_its_code() -> None:
472
+ # Run the script with a stub colab_cli, to see the order and the exit code.
473
+ import subprocess as sp
474
+ import tempfile
475
+ from pathlib import Path
476
+
477
+ with tempfile.TemporaryDirectory() as root:
478
+ stub = Path(root, "stub")
479
+ (stub / "colab_cli").mkdir(parents=True)
480
+ (stub / "colab_cli" / "__init__.py").write_text("")
481
+ (stub / "colab_cli" / "cli.py").write_text(
482
+ "import sys\n"
483
+ "import google.auth.transport.requests as r\n"
484
+ "def main():\n"
485
+ " print(r._DEFAULT_TIMEOUT, r.AuthorizedSession().request('GET', 'u'), sys.argv)\n"
486
+ " raise SystemExit(3)\n"
487
+ )
488
+ google = Path(root, "stub", "google", "auth", "transport")
489
+ google.mkdir(parents=True)
490
+ for d in (google.parent.parent, google.parent, google):
491
+ (d / "__init__.py").write_text("")
492
+ (google / "requests.py").write_text(
493
+ "_DEFAULT_TIMEOUT = 120\n"
494
+ "class AuthorizedSession:\n"
495
+ " def request(self, method, url, timeout=_DEFAULT_TIMEOUT):\n"
496
+ " return timeout\n"
497
+ )
498
+ done = sp.run(
499
+ [sys.executable, "-c", colab_module.NEW_SCRIPT, "new", "-s", "x"],
500
+ capture_output=True, text=True, env={"PYTHONPATH": str(stub)},
501
+ )
502
+ assert done.returncode == 3, done.stderr
503
+ assert done.stdout.strip() == "600 600 ['colab', 'new', '-s', 'x']"
504
+
505
+
506
+ def test_a_failed_colab_new_raises_with_the_command_and_stderr(
507
+ isolated_home, patch_which, patch_run
508
+ ) -> None:
509
+ provider = _colab_with(patch_which, patch_run, FakeColabCli(new_fails=True))
510
+ with pytest.raises(letify.RuntimeFailure) as caught:
511
+ provider.create_session(provider.A100, "letify-a100-1")
512
+ assert "ReadTimeout" in caught.value.stderr
513
+ assert "new -s letify-a100-1 --gpu A100" in caught.value.command
445
514
 
446
515
 
447
516
  def test_a_failing_cli_command_names_the_command_and_the_end_of_its_stderr(
@@ -2706,6 +2706,81 @@ def test_a_persistent_provider_that_builds_its_venv_elsewhere_archives_on_its_wo
2706
2706
  assert "caching the environment on the workspace root" in err, err
2707
2707
 
2708
2708
 
2709
+ def test_the_workspace_pack_names_its_temporary_file_without_the_process_id(
2710
+ tmp_path: Path, monkeypatch
2711
+ ) -> None:
2712
+ # Spec "Volumes on a persistent runtime": every Modal sandbox runs its worker under the
2713
+ # same small pid, so the temporary name carries 16 random hex digits instead.
2714
+ import re
2715
+ import subprocess
2716
+
2717
+ project = tmp_path / "proj"
2718
+ project.mkdir()
2719
+ (project / "a.txt").write_text("a")
2720
+ archive = tmp_path / "vol" / "env.tar"
2721
+ seen: list[str] = []
2722
+ real = subprocess.run
2723
+
2724
+ def record(cmd, *args, **kwargs):
2725
+ seen.append(cmd[2])
2726
+ return real(cmd, *args, **kwargs)
2727
+
2728
+ monkeypatch.setattr(subprocess, "run", record)
2729
+ source = bootstrap.workspace_pack_source(str(project), str(archive))
2730
+ for _ in range(2):
2731
+ exec(source, {})
2732
+ assert len(seen) == 2 and seen[0] != seen[1]
2733
+ for name in seen:
2734
+ assert re.fullmatch(re.escape(str(archive)) + r"\.[0-9a-f]{16}\.partial", name), name
2735
+ assert archive.is_file()
2736
+ assert not list(archive.parent.glob("*.partial"))
2737
+
2738
+
2739
+ def test_a_pack_that_fails_removes_its_temporary_file(tmp_path: Path) -> None:
2740
+ # Spec "Volumes on a persistent runtime": a failed pack leaves no partial behind.
2741
+ project = tmp_path / "proj"
2742
+ project.mkdir()
2743
+ archive = tmp_path / "vol" / "env.tar"
2744
+ source = bootstrap.workspace_pack_source(str(project), str(archive)).replace(
2745
+ "['tar', '-cf', _partial,", "['sh', '-c', 'echo x > \"$0\"; exit 2', _partial,"
2746
+ )
2747
+ with pytest.raises(subprocess.CalledProcessError):
2748
+ exec(source, {})
2749
+ assert not list(archive.parent.glob("*.partial")), "a temporary archive was left behind"
2750
+ assert not archive.exists()
2751
+
2752
+
2753
+ def test_a_failed_workspace_pack_does_not_fail_the_session(
2754
+ uv_project: Path, tmp_path: Path, monkeypatch, capsys
2755
+ ) -> None:
2756
+ # Spec "Volumes on a persistent runtime": the environment is already built, so a failed
2757
+ # cache is said and the session goes on.
2758
+ provider, volume = _elsewhere_provider(tmp_path)
2759
+ env = Env()
2760
+ real = bootstrap.workspace_pack_source
2761
+
2762
+ def failing(root: str, archive: str) -> str:
2763
+ return real(root, archive).replace(
2764
+ "['tar', '-cf', _partial,", "['sh', '-c', 'echo x > \"$0\"; exit 2', _partial,"
2765
+ )
2766
+
2767
+ monkeypatch.setattr(bootstrap, "workspace_pack_source", failing)
2768
+ instance = Instance(provider, gpu=None)._placed("remote")
2769
+ runtime = provider.start(instance, env, name="lab-1", volumes=(volume,))
2770
+ try:
2771
+ assert runtime.env_source == "sync"
2772
+ assert runtime.eval("__letify_value__ = 6 * 7") == 42
2773
+ archive = _workspace_archive(env, runtime.platform)
2774
+ finally:
2775
+ runtime.shutdown()
2776
+ err = capsys.readouterr().err
2777
+ assert (
2778
+ "not caching the environment on the workspace root (RemoteError: CalledProcessError" in err
2779
+ ), err
2780
+ assert not archive.exists()
2781
+ assert not list(archive.parent.glob("*.partial"))
2782
+
2783
+
2709
2784
  def test_a_second_session_restores_the_environment_from_the_workspace_root(
2710
2785
  uv_project: Path, tmp_path: Path, monkeypatch, capsys
2711
2786
  ) -> None:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes