letify 2.5.1__tar.gz → 2.5.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 (210) hide show
  1. {letify-2.5.1 → letify-2.5.3}/PKG-INFO +1 -1
  2. {letify-2.5.1 → letify-2.5.3}/docs/SPEC.md +23 -2
  3. {letify-2.5.1 → letify-2.5.3}/letify/__init__.py +1 -1
  4. {letify-2.5.1 → letify-2.5.3}/letify/cli.py +47 -2
  5. {letify-2.5.1 → letify-2.5.3}/letify/protocol/worker.py +57 -4
  6. {letify-2.5.1 → letify-2.5.3}/letify/providers/kaggle_adapter.py +10 -4
  7. {letify-2.5.1 → letify-2.5.3}/letify/remoting/device/client.py +17 -3
  8. {letify-2.5.1 → letify-2.5.3}/letify/runtime/channel.py +2 -2
  9. {letify-2.5.1 → letify-2.5.3}/letify/store/pathdata.py +48 -4
  10. {letify-2.5.1 → letify-2.5.3}/letify-core/Cargo.lock +3 -3
  11. {letify-2.5.1 → letify-2.5.3}/letify-core/Cargo.toml +1 -1
  12. {letify-2.5.1 → letify-2.5.3}/letify-ext/package-lock.json +2 -2
  13. {letify-2.5.1 → letify-2.5.3}/letify-ext/package.json +1 -1
  14. {letify-2.5.1 → letify-2.5.3}/pyproject.toml +1 -1
  15. {letify-2.5.1 → letify-2.5.3}/tests/fake_kaggle_adapter.py +3 -7
  16. {letify-2.5.1 → letify-2.5.3}/tests/test_cli.py +58 -0
  17. {letify-2.5.1 → letify-2.5.3}/tests/test_data.py +46 -5
  18. {letify-2.5.1 → letify-2.5.3}/tests/test_data_streaming.py +90 -0
  19. {letify-2.5.1 → letify-2.5.3}/tests/test_kaggle.py +45 -2
  20. {letify-2.5.1 → letify-2.5.3}/tests/test_protocol.py +2 -2
  21. {letify-2.5.1 → letify-2.5.3}/tests/test_providers.py +2 -2
  22. {letify-2.5.1 → letify-2.5.3}/.github/workflows/ci.yml +0 -0
  23. {letify-2.5.1 → letify-2.5.3}/.github/workflows/docs.yml +0 -0
  24. {letify-2.5.1 → letify-2.5.3}/.github/workflows/publish.yml +0 -0
  25. {letify-2.5.1 → letify-2.5.3}/.gitignore +0 -0
  26. {letify-2.5.1 → letify-2.5.3}/.researchtree +0 -0
  27. {letify-2.5.1 → letify-2.5.3}/CLAUDE.md +0 -0
  28. {letify-2.5.1 → letify-2.5.3}/LICENSE +0 -0
  29. {letify-2.5.1 → letify-2.5.3}/PROJECT.md +0 -0
  30. {letify-2.5.1 → letify-2.5.3}/README.md +0 -0
  31. {letify-2.5.1 → letify-2.5.3}/docs/COMPONENT.md +0 -0
  32. {letify-2.5.1 → letify-2.5.3}/docs/INTENT.md +0 -0
  33. {letify-2.5.1 → letify-2.5.3}/docs/NETWORK.md +0 -0
  34. {letify-2.5.1 → letify-2.5.3}/docs/guide/01-getting-started.md +0 -0
  35. {letify-2.5.1 → letify-2.5.3}/docs/guide/02-providers.md +0 -0
  36. {letify-2.5.1 → letify-2.5.3}/docs/guide/03-execution-modes.md +0 -0
  37. {letify-2.5.1 → letify-2.5.3}/docs/guide/04-environments-and-data.md +0 -0
  38. {letify-2.5.1 → letify-2.5.3}/docs/guide/05-concurrency.md +0 -0
  39. {letify-2.5.1 → letify-2.5.3}/docs/guide/06-cost.md +0 -0
  40. {letify-2.5.1 → letify-2.5.3}/docs/guide/07-troubleshooting.md +0 -0
  41. {letify-2.5.1 → letify-2.5.3}/docs/guide/08-releasing.md +0 -0
  42. {letify-2.5.1 → letify-2.5.3}/docs/guide/README.md +0 -0
  43. {letify-2.5.1 → letify-2.5.3}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  44. {letify-2.5.1 → letify-2.5.3}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  45. {letify-2.5.1 → letify-2.5.3}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  46. {letify-2.5.1 → letify-2.5.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
  47. {letify-2.5.1 → letify-2.5.3}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  48. {letify-2.5.1 → letify-2.5.3}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  49. {letify-2.5.1 → letify-2.5.3}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  50. {letify-2.5.1 → letify-2.5.3}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  51. {letify-2.5.1 → letify-2.5.3}/docs/guide/ko/README.md +0 -0
  52. {letify-2.5.1 → letify-2.5.3}/docs/locales/README_ko.md +0 -0
  53. {letify-2.5.1 → letify-2.5.3}/docs/mkdocs.yml +0 -0
  54. {letify-2.5.1 → letify-2.5.3}/examples/README.md +0 -0
  55. {letify-2.5.1 → letify-2.5.3}/examples/nvfp4-lora/.letify.example +0 -0
  56. {letify-2.5.1 → letify-2.5.3}/examples/nvfp4-lora/00_smoke.py +0 -0
  57. {letify-2.5.1 → letify-2.5.3}/examples/nvfp4-lora/01_sweep.py +0 -0
  58. {letify-2.5.1 → letify-2.5.3}/examples/nvfp4-lora/02_resume.py +0 -0
  59. {letify-2.5.1 → letify-2.5.3}/examples/nvfp4-lora/03_watch.py +0 -0
  60. {letify-2.5.1 → letify-2.5.3}/examples/nvfp4-lora/README.md +0 -0
  61. {letify-2.5.1 → letify-2.5.3}/examples/nvfp4-lora/README_ko.md +0 -0
  62. {letify-2.5.1 → letify-2.5.3}/examples/nvfp4-lora/common.py +0 -0
  63. {letify-2.5.1 → letify-2.5.3}/examples/nvfp4-lora/recipe.py +0 -0
  64. {letify-2.5.1 → letify-2.5.3}/letify/config/__init__.py +0 -0
  65. {letify-2.5.1 → letify-2.5.3}/letify/config/inventory.py +0 -0
  66. {letify-2.5.1 → letify-2.5.3}/letify/config/login.py +0 -0
  67. {letify-2.5.1 → letify-2.5.3}/letify/config/schema.py +0 -0
  68. {letify-2.5.1 → letify-2.5.3}/letify/config/secrets.py +0 -0
  69. {letify-2.5.1 → letify-2.5.3}/letify/config/writer.py +0 -0
  70. {letify-2.5.1 → letify-2.5.3}/letify/declare/__init__.py +0 -0
  71. {letify-2.5.1 → letify-2.5.3}/letify/declare/cache.py +0 -0
  72. {letify-2.5.1 → letify-2.5.3}/letify/declare/env.py +0 -0
  73. {letify-2.5.1 → letify-2.5.3}/letify/declare/function.py +0 -0
  74. {letify-2.5.1 → letify-2.5.3}/letify/declare/instance.py +0 -0
  75. {letify-2.5.1 → letify-2.5.3}/letify/declare/reduce.py +0 -0
  76. {letify-2.5.1 → letify-2.5.3}/letify/declare/replicas.py +0 -0
  77. {letify-2.5.1 → letify-2.5.3}/letify/errors.py +0 -0
  78. {letify-2.5.1 → letify-2.5.3}/letify/install.py +0 -0
  79. {letify-2.5.1 → letify-2.5.3}/letify/launcher.py +0 -0
  80. {letify-2.5.1 → letify-2.5.3}/letify/paths.py +0 -0
  81. {letify-2.5.1 → letify-2.5.3}/letify/protocol/__init__.py +0 -0
  82. {letify-2.5.1 → letify-2.5.3}/letify/protocol/codec.py +0 -0
  83. {letify-2.5.1 → letify-2.5.3}/letify/protocol/driver.py +0 -0
  84. {letify-2.5.1 → letify-2.5.3}/letify/protocol/handle.py +0 -0
  85. {letify-2.5.1 → letify-2.5.3}/letify/protocol/wire.py +0 -0
  86. {letify-2.5.1 → letify-2.5.3}/letify/providers/__init__.py +0 -0
  87. {letify-2.5.1 → letify-2.5.3}/letify/providers/base.py +0 -0
  88. {letify-2.5.1 → letify-2.5.3}/letify/providers/colab.py +0 -0
  89. {letify-2.5.1 → letify-2.5.3}/letify/providers/colab_files.py +0 -0
  90. {letify-2.5.1 → letify-2.5.3}/letify/providers/colab_keepalive.py +0 -0
  91. {letify-2.5.1 → letify-2.5.3}/letify/providers/elice.py +0 -0
  92. {letify-2.5.1 → letify-2.5.3}/letify/providers/kaggle.py +0 -0
  93. {letify-2.5.1 → letify-2.5.3}/letify/providers/local.py +0 -0
  94. {letify-2.5.1 → letify-2.5.3}/letify/providers/modal.py +0 -0
  95. {letify-2.5.1 → letify-2.5.3}/letify/providers/modal_adapter.py +0 -0
  96. {letify-2.5.1 → letify-2.5.3}/letify/providers/naming.py +0 -0
  97. {letify-2.5.1 → letify-2.5.3}/letify/providers/shell.py +0 -0
  98. {letify-2.5.1 → letify-2.5.3}/letify/providers/tunnel.py +0 -0
  99. {letify-2.5.1 → letify-2.5.3}/letify/providers/usage.py +0 -0
  100. {letify-2.5.1 → letify-2.5.3}/letify/remoting/__init__.py +0 -0
  101. {letify-2.5.1 → letify-2.5.3}/letify/remoting/capability.py +0 -0
  102. {letify-2.5.1 → letify-2.5.3}/letify/remoting/device/__init__.py +0 -0
  103. {letify-2.5.1 → letify-2.5.3}/letify/remoting/device/cuda.py +0 -0
  104. {letify-2.5.1 → letify-2.5.3}/letify/remoting/device/executor.py +0 -0
  105. {letify-2.5.1 → letify-2.5.3}/letify/remoting/device/frames.py +0 -0
  106. {letify-2.5.1 → letify-2.5.3}/letify/remoting/device/guard.py +0 -0
  107. {letify-2.5.1 → letify-2.5.3}/letify/remoting/device/tensor.py +0 -0
  108. {letify-2.5.1 → letify-2.5.3}/letify/remoting/device/trace.py +0 -0
  109. {letify-2.5.1 → letify-2.5.3}/letify/remoting/device/value.py +0 -0
  110. {letify-2.5.1 → letify-2.5.3}/letify/remoting/loader.py +0 -0
  111. {letify-2.5.1 → letify-2.5.3}/letify/remoting/probe.py +0 -0
  112. {letify-2.5.1 → letify-2.5.3}/letify/render.py +0 -0
  113. {letify-2.5.1 → letify-2.5.3}/letify/runtime/__init__.py +0 -0
  114. {letify-2.5.1 → letify-2.5.3}/letify/runtime/bootstrap.py +0 -0
  115. {letify-2.5.1 → letify-2.5.3}/letify/runtime/broker.py +0 -0
  116. {letify-2.5.1 → letify-2.5.3}/letify/runtime/lease.py +0 -0
  117. {letify-2.5.1 → letify-2.5.3}/letify/runtime/pool.py +0 -0
  118. {letify-2.5.1 → letify-2.5.3}/letify/runtime/session.py +0 -0
  119. {letify-2.5.1 → letify-2.5.3}/letify/runtime/telemetry.py +0 -0
  120. {letify-2.5.1 → letify-2.5.3}/letify/store/__init__.py +0 -0
  121. {letify-2.5.1 → letify-2.5.3}/letify/store/backends/__init__.py +0 -0
  122. {letify-2.5.1 → letify-2.5.3}/letify/store/backends/filesystem.py +0 -0
  123. {letify-2.5.1 → letify-2.5.3}/letify/store/backends/google_auth.py +0 -0
  124. {letify-2.5.1 → letify-2.5.3}/letify/store/backends/layout.py +0 -0
  125. {letify-2.5.1 → letify-2.5.3}/letify/store/backends/objects.py +0 -0
  126. {letify-2.5.1 → letify-2.5.3}/letify/store/cas.py +0 -0
  127. {letify-2.5.1 → letify-2.5.3}/letify/store/sendorder.py +0 -0
  128. {letify-2.5.1 → letify-2.5.3}/letify/store/volume.py +0 -0
  129. {letify-2.5.1 → letify-2.5.3}/letify/stubs.py +0 -0
  130. {letify-2.5.1 → letify-2.5.3}/letify/tools.py +0 -0
  131. {letify-2.5.1 → letify-2.5.3}/letify/transport/__init__.py +0 -0
  132. {letify-2.5.1 → letify-2.5.3}/letify/transport/agent.py +0 -0
  133. {letify-2.5.1 → letify-2.5.3}/letify/transport/announce.py +0 -0
  134. {letify-2.5.1 → letify-2.5.3}/letify/transport/link.py +0 -0
  135. {letify-2.5.1 → letify-2.5.3}/letify/transport/nat.py +0 -0
  136. {letify-2.5.1 → letify-2.5.3}/letify/transport/pipeline.py +0 -0
  137. {letify-2.5.1 → letify-2.5.3}/letify/transport/portmap.py +0 -0
  138. {letify-2.5.1 → letify-2.5.3}/letify/transport/probe.py +0 -0
  139. {letify-2.5.1 → letify-2.5.3}/letify/transport/quic.py +0 -0
  140. {letify-2.5.1 → letify-2.5.3}/letify/transport/rendezvous.py +0 -0
  141. {letify-2.5.1 → letify-2.5.3}/letify/transport/setup.py +0 -0
  142. {letify-2.5.1 → letify-2.5.3}/letify/transport/sshopts.py +0 -0
  143. {letify-2.5.1 → letify-2.5.3}/letify/transport/strategies.py +0 -0
  144. {letify-2.5.1 → letify-2.5.3}/letify-core/.gitignore +0 -0
  145. {letify-2.5.1 → letify-2.5.3}/letify-core/build.py +0 -0
  146. {letify-2.5.1 → letify-2.5.3}/letify-core/letify-agent/Cargo.toml +0 -0
  147. {letify-2.5.1 → letify-2.5.3}/letify-core/letify-agent/src/driver.rs +0 -0
  148. {letify-2.5.1 → letify-2.5.3}/letify-core/letify-agent/src/main.rs +0 -0
  149. {letify-2.5.1 → letify-2.5.3}/letify-core/letify-driver/Cargo.toml +0 -0
  150. {letify-2.5.1 → letify-2.5.3}/letify-core/letify-driver/src/client.rs +0 -0
  151. {letify-2.5.1 → letify-2.5.3}/letify-core/letify-driver/src/exports.rs +0 -0
  152. {letify-2.5.1 → letify-2.5.3}/letify-core/letify-driver/src/image.rs +0 -0
  153. {letify-2.5.1 → letify-2.5.3}/letify-core/letify-driver/src/lib.rs +0 -0
  154. {letify-2.5.1 → letify-2.5.3}/letify-core/letify-driver/src/status.rs +0 -0
  155. {letify-2.5.1 → letify-2.5.3}/letify-core/letify-driver/src/table.rs +0 -0
  156. {letify-2.5.1 → letify-2.5.3}/letify-core/letify-quic/Cargo.toml +0 -0
  157. {letify-2.5.1 → letify-2.5.3}/letify-core/letify-quic/src/main.rs +0 -0
  158. {letify-2.5.1 → letify-2.5.3}/letify-core/letify-wire/Cargo.toml +0 -0
  159. {letify-2.5.1 → letify-2.5.3}/letify-core/letify-wire/src/lib.rs +0 -0
  160. {letify-2.5.1 → letify-2.5.3}/letify-core/letify-wire/tests/throughput.rs +0 -0
  161. {letify-2.5.1 → letify-2.5.3}/letify-core/rust-toolchain.toml +0 -0
  162. {letify-2.5.1 → letify-2.5.3}/letify-ext/.vscodeignore +0 -0
  163. {letify-2.5.1 → letify-2.5.3}/letify-ext/LICENSE +0 -0
  164. {letify-2.5.1 → letify-2.5.3}/letify-ext/README.md +0 -0
  165. {letify-2.5.1 → letify-2.5.3}/letify-ext/media/icon.svg +0 -0
  166. {letify-2.5.1 → letify-2.5.3}/letify-ext/src/cli.ts +0 -0
  167. {letify-2.5.1 → letify-2.5.3}/letify-ext/src/extension.ts +0 -0
  168. {letify-2.5.1 → letify-2.5.3}/letify-ext/src/history.ts +0 -0
  169. {letify-2.5.1 → letify-2.5.3}/letify-ext/src/model.ts +0 -0
  170. {letify-2.5.1 → letify-2.5.3}/letify-ext/src/poll.ts +0 -0
  171. {letify-2.5.1 → letify-2.5.3}/letify-ext/src/render.ts +0 -0
  172. {letify-2.5.1 → letify-2.5.3}/letify-ext/test/cli.test.ts +0 -0
  173. {letify-2.5.1 → letify-2.5.3}/letify-ext/test/manifest.test.ts +0 -0
  174. {letify-2.5.1 → letify-2.5.3}/letify-ext/test/model.test.ts +0 -0
  175. {letify-2.5.1 → letify-2.5.3}/letify-ext/test/regressions.test.ts +0 -0
  176. {letify-2.5.1 → letify-2.5.3}/letify-ext/tsconfig.json +0 -0
  177. {letify-2.5.1 → letify-2.5.3}/letify_providers.py +0 -0
  178. {letify-2.5.1 → letify-2.5.3}/scripts/sync-main.sh +0 -0
  179. {letify-2.5.1 → letify-2.5.3}/scripts/version.py +0 -0
  180. {letify-2.5.1 → letify-2.5.3}/tests/conftest.py +0 -0
  181. {letify-2.5.1 → letify-2.5.3}/tests/fake_modal_adapter.py +0 -0
  182. {letify-2.5.1 → letify-2.5.3}/tests/test_cli_output.py +0 -0
  183. {letify-2.5.1 → letify-2.5.3}/tests/test_colab_keepalive.py +0 -0
  184. {letify-2.5.1 → letify-2.5.3}/tests/test_config.py +0 -0
  185. {letify-2.5.1 → letify-2.5.3}/tests/test_core.py +0 -0
  186. {letify-2.5.1 → letify-2.5.3}/tests/test_declare.py +0 -0
  187. {letify-2.5.1 → letify-2.5.3}/tests/test_device.py +0 -0
  188. {letify-2.5.1 → letify-2.5.3}/tests/test_distributed.py +0 -0
  189. {letify-2.5.1 → letify-2.5.3}/tests/test_editor_sessions.py +0 -0
  190. {letify-2.5.1 → letify-2.5.3}/tests/test_elice.py +0 -0
  191. {letify-2.5.1 → letify-2.5.3}/tests/test_frames.py +0 -0
  192. {letify-2.5.1 → letify-2.5.3}/tests/test_install.py +0 -0
  193. {letify-2.5.1 → letify-2.5.3}/tests/test_kaggle_cookie.py +0 -0
  194. {letify-2.5.1 → letify-2.5.3}/tests/test_kaggle_link.py +0 -0
  195. {letify-2.5.1 → letify-2.5.3}/tests/test_link_log.py +0 -0
  196. {letify-2.5.1 → letify-2.5.3}/tests/test_notebook.py +0 -0
  197. {letify-2.5.1 → letify-2.5.3}/tests/test_parallel_placement.py +0 -0
  198. {letify-2.5.1 → letify-2.5.3}/tests/test_paths.py +0 -0
  199. {letify-2.5.1 → letify-2.5.3}/tests/test_portmap.py +0 -0
  200. {letify-2.5.1 → letify-2.5.3}/tests/test_quic.py +0 -0
  201. {letify-2.5.1 → letify-2.5.3}/tests/test_remoting.py +0 -0
  202. {letify-2.5.1 → letify-2.5.3}/tests/test_render.py +0 -0
  203. {letify-2.5.1 → letify-2.5.3}/tests/test_runtime.py +0 -0
  204. {letify-2.5.1 → letify-2.5.3}/tests/test_ship.py +0 -0
  205. {letify-2.5.1 → letify-2.5.3}/tests/test_store.py +0 -0
  206. {letify-2.5.1 → letify-2.5.3}/tests/test_strategies.py +0 -0
  207. {letify-2.5.1 → letify-2.5.3}/tests/test_stubs.py +0 -0
  208. {letify-2.5.1 → letify-2.5.3}/tests/test_transport.py +0 -0
  209. {letify-2.5.1 → letify-2.5.3}/tests/test_tunnel_setup.py +0 -0
  210. {letify-2.5.1 → letify-2.5.3}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.5.1
3
+ Version: 2.5.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
@@ -656,7 +656,15 @@ A **persistent channel** keeps one worker process alive behind a pipe pair. Mess
656
656
 
657
657
  A **one-shot channel** can only run a command and collect its output. Every call starts a fresh process, so nothing persists. It exists because some transports offer nothing more, and it refuses the operations that need persistence rather than pretending.
658
658
 
659
- The worker source cannot be sent on standard input as a script, because `python -` reads to end of file before compiling anything and the pipe has to stay open for requests. A small bootstrap stub passed with `-c` reads one line holding a decimal byte count from `sys.stdin.buffer`, reads that many bytes of UTF-8 worker source, executes them, and leaves standard input where it was. From then on both pipes carry frames only. The stub compares what it read against the count and raises `SystemExit` with `the letify worker source arrived short: <read> of <expected> bytes` rather than compiling a truncated program, because a program cut anywhere fails as a `SyntaxError` at whatever construct the cut landed inside, which names the symptom and hides the cause.
659
+ The worker source cannot be sent on standard input as a script, because `python -` reads to end of file before compiling anything and the pipe has to stay open for requests. A small bootstrap stub passed with `-c` reads one line holding a decimal byte count from `sys.stdin.buffer`, reads that many bytes of UTF-8 worker source, executes them, and leaves standard input where it was. From then on both pipes carry frames only. The count line is `<byte count> <digest>`, where the digest is the first 16 bytes of the source's
660
+ blake2b in hexadecimal, from `hashlib` so the stub needs nothing installed. The stub exits rather
661
+ than compiling when either disagrees: `the letify worker source arrived short: <read> of <expected>
662
+ bytes`, or `the letify worker source arrived corrupted: <read> bytes, digest <found>, expected
663
+ <wanted>`. A program cut or shifted anywhere fails as a `SyntaxError` at whatever construct the
664
+ damage landed inside, which names the symptom and hides the cause. The count alone is not enough,
665
+ because a transport that loses one run of bytes and gains another of the same length keeps the count
666
+ intact: a Kaggle session reported an unterminated triple-quoted string at a line that holds no quote
667
+ in the source, which is what a shifted source looks like and what a digest catches.
660
668
 
661
669
  ### Frames <!-- id: frames -->
662
670
 
@@ -1090,6 +1098,15 @@ A detected path that exists is an input. A detected directory and a detected pat
1090
1098
 
1091
1099
  Any other path is pickled unchanged, so the body receives the local path as it was. Only `os.PathLike` objects are detected. A string that happens to name a local file is left alone, because no rule tells a path from any other string.
1092
1100
 
1101
+ One other path is refused rather than pickled unchanged: an `os.PathLike` naming a file or directory that exists on this machine and lies outside every allowed root. Passing it on leaves the body reading a path that is not there, which fails as `FileNotFoundError` on the runtime and names neither the cause nor the rule, and on a runtime where something does sit at that path it reads the wrong bytes instead. So the call raises `ValueError` before it is sent:
1102
+
1103
+ ```
1104
+ <path> is outside the roots letify may send data from. The project root is <root>. Add its
1105
+ directory to [tool.letify] data_roots in <root>/pyproject.toml, or move the file inside the root.
1106
+ ```
1107
+
1108
+ A path that exists here and is inside a root is data; a path that does not exist here is a runtime path and still travels unchanged, because that is how a body is told where to write.
1109
+
1093
1110
  A directory is walked without following symbolic links to directories. Regular files and symbolic links to regular files are included. Entries named `.git`, `.venv` and `__pycache__` are skipped.
1094
1111
 
1095
1112
  #### Digests and the digest cache <!-- id: project-data-digests -->
@@ -1282,6 +1299,10 @@ A call that detected data prints one line on standard error:
1282
1299
 
1283
1300
  The line is printed when the call has at least one input file, after the call's outcome is known, because what was sent during the call is not known before then. Sizes are in MiB with one decimal. `<the bucket>` is named when the bytes come from the bucket. `before the call` counts the first wave of The send order and the first wave, `during it` counts what the background sender delivered while the call ran, and the rate covers both. `first access waited` is the wall clock time in which at least one reader was blocked on a blob that had not arrived, and it is `0.0` when the send order was right.
1284
1301
 
1302
+ `already on` counts the blobs the runtime answered `data_have` with, not what is left after subtracting what was sent. The two differ whenever a blob was neither already there nor fully sent, and subtracting reports such a blob as having been there all along, which is the opposite of what happened. A blob the background sender was still carrying when the call returned is one of those, so the line ends with `, <files> files <size> dropped when the call ended` whenever there is one, and omits that clause when there is none. A dropped blob is not a failure: the call is over and its bytes are no longer needed, which is why the sender is cancelled. It is reported because a line that accounts for every detected file except the dropped ones cannot be read.
1303
+
1304
+ Cancelling the sender removes the partial file of a blob it was writing, in the runtime's blob cache as well as a parallel put's staged ranges. A full sized partial left behind counts against the cache budget and is never completed, because the digest it would be sealed under is only written when every range has arrived.
1305
+
1285
1306
  A call with an output location that returned prints a second line after the write-back:
1286
1307
 
1287
1308
  `letify: data wrote back <files> files <size> in <seconds> s (<rate> MiB/s), <files> files <size> during the call, <files> files <size> already on the client`
@@ -1348,7 +1369,7 @@ Sizes are in MiB with one decimal and `<budget>` in GiB with one decimal.
1348
1369
 
1349
1370
  `letify cache` shows the client digest cache, every local tree letify owns with its file count and size, and each provider's runtime file blob cache. `letify cache clear <target>` (or `letify cache clean <target>`) empties what is safe to clean. Credentials and configuration are never cleaned.
1350
1371
 
1351
- `letify cache` first removes every digest cache entry whose file no longer exists, then prints the digest cache's entry count and how many were removed. It measures every local directory under `~/.letify`: `cache` (including digest cache, filesystem stores, and redirected CLI homes), `tools` (installed binaries), `tmp` (temporary session files), `ssh` (multiplexing control sockets), `runtime` (local provider workspace), and `accounts` (credentials, marked non-cleanable). For each provider in the configuration whose runtime disk persists between sessions, it starts a session on the provider's first instance, asks the worker with one `data_cache` request, and prints the alias, the blob count, the total size and the budget. A provider whose runtime disk does not persist is listed with `not kept between sessions` and no session is started. `--json` prints the same records under `digests`, `local` and `providers`.
1372
+ `letify cache` first removes every digest cache entry whose file no longer exists, then prints the digest cache's entry count and how many were removed. It measures every local directory under `~/.letify`: `cache` (including digest cache, filesystem stores, and redirected CLI homes), `tools` (installed binaries), `tmp` (temporary session files), `ssh` (multiplexing control sockets), `runtime` (local provider workspace), and `accounts` (credentials, marked non-cleanable). For each provider in the configuration whose runtime disk persists between sessions and already has a session running, it asks the worker with one `data_cache` request and prints the alias, the blob count, the total size and the budget. It starts a session only where starting one bills nothing, which is a provider whose runtime is this machine: a command that shows what is there must not create what is billed, and a configuration naming several providers would bill for all of them at once to print a listing. A provider with no session running is listed with `no session running`, and a provider whose runtime disk does not persist with `not kept between sessions`. Clearing a named provider still starts a session, because there the user named the provider and asked for the work. `--json` prints the same records under `digests`, `local` and `providers`.
1352
1373
 
1353
1374
  `letify cache clear <target>` (or `letify cache clean <target>`) clears caches:
1354
1375
  1. When `<target>` is a provider alias, it sends `data_cache` with `clear` set to that runtime. The worker removes committed blobs whose link count is 1, and the command prints `<alias>: removed <files> files <size>`.
@@ -66,7 +66,7 @@ distributed: Final = Host.distributed
66
66
  # The enum class stays importable from letify.declare.instance for letify's own use.
67
67
  del Host
68
68
 
69
- __version__ = "2.5.1"
69
+ __version__ = "2.5.3"
70
70
 
71
71
 
72
72
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -322,8 +322,41 @@ def _setup_tool(args: argparse.Namespace) -> int:
322
322
  return 0
323
323
 
324
324
 
325
+ def _running_runtime(let: Launcher, alias: str):
326
+ """A session this process already holds on that provider, or None.
327
+
328
+ Spec "The cache command": the listing asks only what is already running, because a
329
+ session it started would bill for the listing.
330
+ """
331
+ for runtime in let.pool.live:
332
+ if getattr(runtime.provider, "alias", None) == alias:
333
+ return runtime
334
+ return None
335
+
336
+
337
+ def _data_cache_reply(let: Launcher, runtime, clear: bool) -> dict:
338
+ """One ``data_cache`` request to a session that is already up."""
339
+ from .store import pathdata
340
+
341
+ provider = runtime.provider
342
+ root = (runtime.workspace or provider.workspace_root).rstrip("/")
343
+ return runtime.request(
344
+ {
345
+ "op": "data_cache",
346
+ "dir": f"{root}/data/blobs",
347
+ "budget": pathdata.budget_bytes(provider),
348
+ "clear": clear,
349
+ },
350
+ timeout=600,
351
+ )
352
+
353
+
325
354
  def _data_cache_request(let: Launcher, alias: str, clear: bool) -> dict:
326
- """Ask a session on the provider's first instance about its file blob cache."""
355
+ """Ask a session on the provider's first instance about its file blob cache.
356
+
357
+ This starts one when none is running, so only a named provider reaches it. Spec "The
358
+ cache command".
359
+ """
327
360
  from .declare import Env
328
361
  from .store import pathdata
329
362
 
@@ -462,7 +495,16 @@ def _cache(let: Launcher, args: argparse.Namespace) -> int:
462
495
  if not provider.persistent:
463
496
  records.append({"alias": alias, "kept": False})
464
497
  continue
465
- result = _data_cache_request(let, alias, False)
498
+ # Spec "The cache command": a listing bills nothing, so it starts a session
499
+ # only where starting one is free, and otherwise asks one that is already up.
500
+ if provider.kind == "local":
501
+ result = _data_cache_request(let, alias, False)
502
+ else:
503
+ runtime = _running_runtime(let, alias)
504
+ if runtime is None:
505
+ records.append({"alias": alias, "kept": True, "running": False})
506
+ continue
507
+ result = _data_cache_reply(let, runtime, False)
466
508
  except LetifyError as exc:
467
509
  records.append({"alias": alias, "unavailable": str(exc)})
468
510
  continue
@@ -470,6 +512,7 @@ def _cache(let: Launcher, args: argparse.Namespace) -> int:
470
512
  {
471
513
  "alias": alias,
472
514
  "kept": True,
515
+ "running": True,
473
516
  "files": result["files"],
474
517
  "bytes": result["bytes"],
475
518
  "budget": result["budget"],
@@ -498,6 +541,8 @@ def _cache(let: Launcher, args: argparse.Namespace) -> int:
498
541
  rows.append([row["alias"], f"unavailable: {row['unavailable']}", "", ""])
499
542
  elif not row["kept"]:
500
543
  rows.append([row["alias"], "not kept between sessions", "", ""])
544
+ elif not row.get("running"):
545
+ rows.append([row["alias"], "no session running", "", ""])
501
546
  else:
502
547
  rows.append(
503
548
  [
@@ -37,17 +37,35 @@ from . import wire as _wire
37
37
  #: enough to survive shell quoting reads a byte count line and that many bytes of source,
38
38
  #: execs them, and leaves standard input where it was.
39
39
  BOOTSTRAP = (
40
- "import sys;"
40
+ "import sys,hashlib;"
41
41
  "b=sys.stdin.buffer;"
42
- "n=int(b.readline());"
42
+ "h=b.readline().split();"
43
+ "n=int(h[0]);"
43
44
  "s=b.read(n);"
44
- # Spec "Channels": a source cut anywhere compiles as whatever construct the cut landed
45
- # inside, or runs and exits without serving, so the count is checked before it is run.
45
+ # Spec "Channels": a source cut or shifted anywhere compiles as whatever construct the
46
+ # damage landed inside, or runs and exits without serving. The count catches a cut and
47
+ # the digest catches a shift, which keeps the count intact.
46
48
  "len(s)==n or sys.exit("
47
49
  "'the letify worker source arrived short: %d of %d bytes'%(len(s),n));"
50
+ "d=hashlib.blake2b(s,digest_size=16).hexdigest();"
51
+ "d==h[1].decode() or sys.exit("
52
+ "'the letify worker source arrived corrupted: %d bytes, digest %s, expected %s'"
53
+ "%(len(s),d,h[1].decode()));"
48
54
  "exec(compile(s.decode(),'letify-worker','exec'))"
49
55
  )
50
56
 
57
+ def count_line(body: bytes) -> bytes:
58
+ """The line the bootstrap stub reads before the source: its length and its digest.
59
+
60
+ Spec "Channels": the digest is the first 16 bytes of blake2b in hexadecimal, from
61
+ ``hashlib`` so the stub needs nothing installed.
62
+ """
63
+ import hashlib
64
+
65
+ digest = hashlib.blake2b(body, digest_size=16).hexdigest()
66
+ return b"%d %s\n" % (len(body), digest.encode("ascii"))
67
+
68
+
51
69
  _BODY = r'''
52
70
  import hashlib, io, queue, sys, tarfile, time, traceback
53
71
 
@@ -837,6 +855,40 @@ def _parallel_partial(root, digest):
837
855
  return "%s%s.parallel" % (_data_file(root, digest), _DATA_PARTIAL)
838
856
 
839
857
 
858
+ def _op_data_drop(request):
859
+ """Remove the partials of a blob nobody is going to finish.
860
+
861
+ Spec "Data log line": a sender cancelled when the call ended leaves a partial that no
862
+ later request completes, because the digest it would be sealed under is written only
863
+ once every piece has arrived. A single put's partial is invisible to the budget scan,
864
+ so without this it stays for the life of the runtime.
865
+ """
866
+ digest = request["digest"]
867
+ final = _data_file(request["dir"], digest)
868
+ open_handle = _DATA_OPEN.pop(digest, None)
869
+ if open_handle is not None:
870
+ try:
871
+ open_handle[0].close()
872
+ except OSError:
873
+ pass
874
+ removed = 0
875
+ directory = os.path.dirname(final)
876
+ name = os.path.basename(final)
877
+ try:
878
+ entries = os.listdir(directory)
879
+ except OSError:
880
+ entries = []
881
+ for entry in entries:
882
+ if entry == name or not entry.startswith(name + "."):
883
+ continue
884
+ try:
885
+ os.remove(os.path.join(directory, entry))
886
+ removed += 1
887
+ except OSError:
888
+ pass
889
+ return {"ok": True, "value": removed}
890
+
891
+
840
892
  def _op_data_begin(request):
841
893
  """Make the file parallel senders will fill, of the size it will end up.
842
894
 
@@ -2236,6 +2288,7 @@ _OPS = {
2236
2288
  "data_have": _op_data_have,
2237
2289
  "data_put": _op_data_put,
2238
2290
  "data_begin": _op_data_begin,
2291
+ "data_drop": _op_data_drop,
2239
2292
  "data_put_at": _op_data_put_at,
2240
2293
  "data_seal": _op_data_seal,
2241
2294
  "data_pull": _op_data_pull,
@@ -31,14 +31,20 @@ UNREACHABLE = 4
31
31
  #: The stub the shim starts the worker with, the one Channels describes: a byte count line,
32
32
  #: then that many bytes of source.
33
33
  STUB = (
34
- "import sys;"
34
+ "import sys,hashlib;"
35
35
  "b=sys.stdin.buffer;"
36
- "n=int(b.readline());"
36
+ "h=b.readline().split();"
37
+ "n=int(h[0]);"
37
38
  "s=b.read(n);"
38
- # Spec "Channels": a source cut anywhere compiles as whatever construct the cut landed
39
- # inside, or runs and exits without serving, so the count is checked before it is run.
39
+ # Spec "Channels": a source cut or shifted anywhere compiles as whatever construct the
40
+ # damage landed inside, or runs and exits without serving. The count catches a cut and
41
+ # the digest catches a shift, which keeps the count intact.
40
42
  "len(s)==n or sys.exit("
41
43
  "'the letify worker source arrived short: %d of %d bytes'%(len(s),n));"
44
+ "d=hashlib.blake2b(s,digest_size=16).hexdigest();"
45
+ "d==h[1].decode() or sys.exit("
46
+ "'the letify worker source arrived corrupted: %d bytes, digest %s, expected %s'"
47
+ "%(len(s),d,h[1].decode()));"
42
48
  "exec(compile(s.decode(),'letify-worker','exec'))"
43
49
  )
44
50
 
@@ -99,9 +99,21 @@ class Pending:
99
99
 
100
100
 
101
101
  #: Passed to ``python -c`` to start the worker: read a length-prefixed source, execute it.
102
+ #: Spec "Channels": the count catches a cut and the digest catches a shift, which keeps the
103
+ #: count intact, so neither failure reaches the compiler as a syntax error.
102
104
  BOOTSTRAP = (
103
- "import sys;n=int(sys.stdin.buffer.readline());"
104
- "exec(compile(sys.stdin.buffer.read(n),'letify-device','exec'))"
105
+ "import sys,hashlib;"
106
+ "b=sys.stdin.buffer;"
107
+ "h=b.readline().split();"
108
+ "n=int(h[0]);"
109
+ "s=b.read(n);"
110
+ "len(s)==n or sys.exit("
111
+ "'the letify device source arrived short: %d of %d bytes'%(len(s),n));"
112
+ "d=hashlib.blake2b(s,digest_size=16).hexdigest();"
113
+ "d==h[1].decode() or sys.exit("
114
+ "'the letify device source arrived corrupted: %d bytes, digest %s, expected %s'"
115
+ "%(len(s),d,h[1].decode()));"
116
+ "exec(compile(s,'letify-device','exec'))"
105
117
  )
106
118
 
107
119
 
@@ -1089,7 +1101,9 @@ def connect(
1089
1101
  target=client._drain_stderr, args=(process.stderr,), name=f"{name}-stderr", daemon=True
1090
1102
  ).start()
1091
1103
  try:
1092
- transport.raw(b"%d\n" % len(source) + source)
1104
+ from ...protocol.worker import count_line
1105
+
1106
+ transport.raw(count_line(source) + source)
1093
1107
  client.start()
1094
1108
  except (TransportClosed, RuntimeLost) as exc:
1095
1109
  process.kill()
@@ -654,14 +654,14 @@ class FramedChannel(Channel):
654
654
 
655
655
  def _send_worker(self) -> None:
656
656
  """Hand the worker source to the bootstrap stub: a byte count line, then the source."""
657
- from ..protocol.worker import source
657
+ from ..protocol.worker import count_line, source
658
658
 
659
659
  connection = self._connection
660
660
  assert connection is not None
661
661
  data = source(text_frames=self.text_frames).encode("utf-8")
662
662
  connection.hello = None
663
663
  try:
664
- connection.sender.raw(b"%d\n" % len(data) + data)
664
+ connection.sender.raw(count_line(data) + data)
665
665
  except OSError:
666
666
  # The worker is already gone. Waiting for its hello reports why.
667
667
  pass
@@ -370,6 +370,17 @@ class Collector:
370
370
  if resolved == project or resolved in project.parents:
371
371
  return None
372
372
  if not any(_inside(resolved, root) for root in roots):
373
+ if info is not None:
374
+ # Spec "Where the bytes come from": a file that exists here and cannot be
375
+ # sent would travel as a path the body cannot read, which fails on the
376
+ # runtime naming neither the cause nor this rule, or reads the wrong bytes
377
+ # where something does sit at that path.
378
+ raise ValueError(
379
+ f"{resolved} is outside the roots letify may send data from. The "
380
+ f"project root is {project}. Add its directory to [tool.letify] "
381
+ f"data_roots in {project}/pyproject.toml, or move the file inside "
382
+ f"the root."
383
+ )
373
384
  return None
374
385
  index = len(self.placed)
375
386
  placed = Placed(resolved, f"{self.call_dir}/{index}/{resolved.name}", is_dir)
@@ -601,6 +612,9 @@ class Stream:
601
612
  self.runtime = runtime
602
613
  self.blobs = blobs
603
614
  self.plan = plan
615
+ #: Digests whose send finished. ``_sent`` holds the ones it began, so a blob the
616
+ #: sender was carrying when the call ended is in one and not the other.
617
+ self.done: set[str] = set()
604
618
  self.before_files = self.before_bytes = 0
605
619
  self.during_files = self.during_bytes = 0
606
620
  self.before_seconds = self.during_seconds = 0.0
@@ -703,6 +717,22 @@ class Stream:
703
717
  if (thread is None or not thread.is_alive()) and self._pipeline is not None:
704
718
  self._pipeline.close()
705
719
  self._pipeline = None
720
+ self._drop_partials()
721
+
722
+ def _drop_partials(self) -> None:
723
+ """Remove the partial of every blob the sender was carrying. Spec "Data log line"."""
724
+ for digest in self.dropped:
725
+ try:
726
+ _worker(self.runtime, {"op": "data_drop", "dir": self.blobs, "digest": digest})
727
+ except (RuntimeFailure, RemoteError):
728
+ # The runtime is going away, which removes the partial with it.
729
+ return
730
+
731
+ @property
732
+ def dropped(self) -> list[str]:
733
+ """Blobs that were neither already on the runtime nor fully sent."""
734
+ with self._lock:
735
+ return [digest for digest in self.plan.missing if digest not in self.done]
706
736
 
707
737
  # -- the stream ---------------------------------------------------------------
708
738
 
@@ -760,6 +790,8 @@ class Stream:
760
790
  if not uploaded:
761
791
  return
762
792
  files, total = self.plan.weight(digest)
793
+ with self._lock:
794
+ self.done.add(digest)
763
795
  if self._thread is None:
764
796
  self.before_files += files
765
797
  self.before_bytes += total
@@ -780,19 +812,31 @@ class Stream:
780
812
 
781
813
  stats = stats or {}
782
814
  plan = self.plan
783
- sent_files = self.before_files + self.during_files
784
815
  sent_bytes = self.before_bytes + self.during_bytes
785
816
  seconds = self.before_seconds + self.during_seconds
786
817
  rate = sent_bytes / (1 << 20) / seconds if seconds > 0 else 0.0
787
- install.log(
818
+ # Spec "Data log line": counted from what the runtime answered data_have with,
819
+ # because subtracting what was sent reports a blob that was neither as having
820
+ # been there all along.
821
+ missing_weight = [plan.weight(digest) for digest in plan.missing]
822
+ already_files = plan.detected_files - sum(files for files, _b in missing_weight)
823
+ already_bytes = plan.detected_bytes - sum(total for _f, total in missing_weight)
824
+ dropped_weight = [plan.weight(digest) for digest in self.dropped]
825
+ dropped_files = sum(files for files, _b in dropped_weight)
826
+ dropped_bytes = sum(total for _f, total in dropped_weight)
827
+ line = (
788
828
  f"data {plan.detected_files} files {_mib(plan.detected_bytes)} detected, "
789
- f"{plan.detected_files - sent_files} files "
790
- f"{_mib(plan.detected_bytes - sent_bytes)} already on {plan.where}, "
829
+ f"{already_files} files {_mib(already_bytes)} already on {plan.where}, "
791
830
  f"sent {self.before_files} files {_mib(self.before_bytes)} before the call in "
792
831
  f"{self.before_seconds:.1f} s, {self.during_files} files "
793
832
  f"{_mib(self.during_bytes)} during it in {self.during_seconds:.1f} s "
794
833
  f"({rate:.1f} MiB/s), first access waited {float(stats.get('seconds', 0.0)):.1f} s"
795
834
  )
835
+ if dropped_files:
836
+ line += (
837
+ f", {dropped_files} files {_mib(dropped_bytes)} dropped when the call ended"
838
+ )
839
+ install.log(line)
796
840
  if stats.get("files"):
797
841
  install.log(
798
842
  f"data waited for {stats['files']} files {_mib(int(stats.get('bytes', 0)))} "
@@ -138,7 +138,7 @@ dependencies = [
138
138
 
139
139
  [[package]]
140
140
  name = "letify-agent"
141
- version = "2.5.1"
141
+ version = "2.5.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.5.1"
149
+ version = "2.5.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.5.1"
167
+ version = "2.5.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.5.1"
11
+ version = "2.5.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.5.1",
3
+ "version": "2.5.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "letify-status",
9
- "version": "2.5.1",
9
+ "version": "2.5.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.5.1",
5
+ "version": "2.5.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.5.1"
3
+ version = "2.5.3"
4
4
  description = "Declarations that become infrastructure."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -63,13 +63,9 @@ except (urllib.error.URLError, OSError) as exc:
63
63
  # The cell a real bridge executes carries a shim, because a kernel answers input() and not a
64
64
  # binary read. Here the same shape is a subprocess whose standard input and output are pipes:
65
65
  # the bridge copies lines in and out, exactly as it would copy them to input_request replies
66
- # and from stream messages.
67
- STUB = (
68
- "import sys;"
69
- "b=sys.stdin.buffer;"
70
- "n=int(b.readline());"
71
- "exec(compile(b.read(n).decode(),'letify-worker','exec'))"
72
- )
66
+ # and from stream messages. The stub is the real one rather than a copy: a copy of it drifted
67
+ # from the real stub when the count line gained a digest, and the drift read as a dead worker.
68
+ from letify.providers.kaggle_adapter import STUB # noqa: E402
73
69
 
74
70
  worker = subprocess.Popen(
75
71
  [sys.executable, "-u", "-c", STUB],
@@ -1606,3 +1606,61 @@ def test_stop_orphans_exits_one_when_a_delete_failed(isolated_home, capsys, monk
1606
1606
  )
1607
1607
  assert main(["stop", "--orphans", "-y"]) == 1
1608
1608
  assert "refused" in capsys.readouterr().out
1609
+
1610
+
1611
+ def test_the_cache_listing_starts_no_session(isolated_home, monkeypatch, capsys) -> None:
1612
+ """Spec "The cache command": a command that shows what is there starts nothing.
1613
+
1614
+ KICT ran `letify cache` and it booted a Modal CPU session, which then failed its own
1615
+ uv sync. A configuration naming several persistent providers would bill for all of
1616
+ them to print a listing.
1617
+ """
1618
+ import letify
1619
+ from letify.cli import main
1620
+ from letify.runtime import pool
1621
+
1622
+ (isolated_home / ".letify" / "config.toml").write_text(
1623
+ '[lab]\nkind = "shell"\naddress = "gpu.example.edu"\npersistent = true\n',
1624
+ encoding="utf-8",
1625
+ )
1626
+ started: list[str] = []
1627
+
1628
+ real = pool.RuntimePool.acquire
1629
+
1630
+ def watch(self, instance, env, volumes=(), **kwargs):
1631
+ provider = instance.provider
1632
+ started.append(provider.alias)
1633
+ assert provider.kind == "local", f"the listing started a billed session on {provider.alias}"
1634
+ return real(self, instance, env, volumes, **kwargs)
1635
+
1636
+ monkeypatch.setattr(pool.RuntimePool, "acquire", watch)
1637
+ assert main(["cache", "--json"]) == 0
1638
+ assert all(alias == "local" for alias in started), started
1639
+ out = capsys.readouterr().out
1640
+ assert "providers" in out
1641
+ assert letify # the import is what makes the launcher reachable in this process
1642
+
1643
+
1644
+ def test_the_cache_listing_says_a_provider_has_no_session_running(
1645
+ isolated_home, capsys
1646
+ ) -> None:
1647
+ """Spec "The cache command": a provider with no session is named, not skipped."""
1648
+ import json
1649
+
1650
+ from letify.cli import main
1651
+
1652
+ (isolated_home / ".letify" / "config.toml").write_text(
1653
+ '[lab]\nkind = "shell"\naddress = "gpu.example.edu"\npersistent = true\n',
1654
+ encoding="utf-8",
1655
+ )
1656
+ assert main(["cache", "--json"]) == 0
1657
+ record = json.loads(capsys.readouterr().out)
1658
+ remote = [
1659
+ row
1660
+ for row in record["providers"]
1661
+ if row.get("kept") and row["alias"] != "local"
1662
+ ]
1663
+ assert remote, "no billed persistent provider in the fixture, so this pins nothing"
1664
+ for row in remote:
1665
+ assert row.get("running") is False, row
1666
+ assert "files" not in row, row
@@ -106,9 +106,12 @@ def test_a_path_that_does_not_exist_outside_the_roots_is_left_as_a_plain_path(
106
106
  assert not absent.exists()
107
107
 
108
108
 
109
- def test_a_path_outside_the_allowed_roots_is_left_as_a_plain_path(
110
- let, cpu, project, tmp_path, capsys
111
- ) -> None:
109
+ def test_a_file_outside_the_allowed_roots_is_refused(let, cpu, project, tmp_path) -> None:
110
+ """Spec "Where the bytes come from": it used to travel as a plain path.
111
+
112
+ The body then read a path that is not on the runtime, which failed there as a
113
+ FileNotFoundError naming neither the cause nor the rule. KICT hit that twice.
114
+ """
112
115
  outside = tmp_path / "elsewhere.txt"
113
116
  outside.write_text("not project data", encoding="utf-8")
114
117
 
@@ -116,8 +119,9 @@ def test_a_path_outside_the_allowed_roots_is_left_as_a_plain_path(
116
119
  def where(path: Path) -> str:
117
120
  return str(path)
118
121
 
119
- assert where(outside) == str(outside)
120
- assert data_lines(capsys.readouterr().err) == []
122
+ with pytest.raises(ValueError) as failure:
123
+ where(outside)
124
+ assert "outside the roots letify may send data from" in str(failure.value)
121
125
 
122
126
 
123
127
  def test_a_data_root_named_in_pyproject_is_allowed(let, cpu, project, tmp_path) -> None:
@@ -780,3 +784,40 @@ def test_blobs_a_write_back_adds_keep_the_cache_within_its_budget(
780
784
  root = _blobs_root()
781
785
  assert sum(p.stat().st_size for p in root.glob("*/*")) <= 20 << 10
782
786
  assert len(evictions(capsys.readouterr().err)) == 1
787
+
788
+
789
+ def test_a_file_outside_the_allowed_roots_is_refused_rather_than_sent_as_a_path(
790
+ let, cpu, project, tmp_path
791
+ ) -> None:
792
+ """Spec "Where the bytes come from": passing it on leaves the body reading nothing.
793
+
794
+ KICT hit this twice on Kaggle: a shard outside the project root travelled as a plain
795
+ path and the call died on the runtime with FileNotFoundError naming only the file.
796
+ """
797
+ outside = tmp_path / "outside" / "shard.pt"
798
+ outside.parent.mkdir(parents=True, exist_ok=True)
799
+ outside.write_bytes(b"x" * 4096)
800
+
801
+ @let.function(device=cpu, host="remote")
802
+ def read(path) -> int:
803
+ import os
804
+
805
+ return os.path.getsize(path)
806
+
807
+ with pytest.raises(ValueError) as failure:
808
+ read(path=outside)
809
+ message = str(failure.value)
810
+ assert str(outside) in message
811
+ assert "data_roots" in message
812
+ assert "outside the roots letify may send data from" in message
813
+
814
+
815
+ def test_a_path_that_does_not_exist_here_still_travels_unchanged(let, cpu, project) -> None:
816
+ """Spec "Where the bytes come from": that is how a body is told where to write."""
817
+
818
+ @let.function(device=cpu, host="remote")
819
+ def name(path) -> str:
820
+ return str(path)
821
+
822
+ target = Path("/not/on/this/machine/out.pt")
823
+ assert name(path=target) == str(target)