letify 2.1.3__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. {letify-2.1.3 → letify-2.2.0}/PKG-INFO +1 -1
  2. {letify-2.1.3 → letify-2.2.0}/letify/__init__.py +1 -1
  3. {letify-2.1.3 → letify-2.2.0}/letify/protocol/worker.py +67 -4
  4. {letify-2.1.3 → letify-2.2.0}/letify/providers/base.py +11 -0
  5. {letify-2.1.3 → letify-2.2.0}/letify/providers/kaggle.py +39 -0
  6. {letify-2.1.3 → letify-2.2.0}/letify/runtime/bootstrap.py +10 -0
  7. {letify-2.1.3 → letify-2.2.0}/letify/runtime/session.py +22 -0
  8. {letify-2.1.3 → letify-2.2.0}/letify/store/pathdata.py +30 -4
  9. {letify-2.1.3 → letify-2.2.0}/letify/transport/link.py +3 -0
  10. {letify-2.1.3 → letify-2.2.0}/letify/transport/pipeline.py +18 -5
  11. {letify-2.1.3 → letify-2.2.0}/letify-core/Cargo.lock +3 -3
  12. {letify-2.1.3 → letify-2.2.0}/letify-core/Cargo.toml +1 -1
  13. {letify-2.1.3 → letify-2.2.0}/letify-ext/package-lock.json +2 -2
  14. {letify-2.1.3 → letify-2.2.0}/letify-ext/package.json +1 -1
  15. {letify-2.1.3 → letify-2.2.0}/pyproject.toml +1 -1
  16. {letify-2.1.3 → letify-2.2.0}/tests/test_core.py +52 -0
  17. {letify-2.1.3 → letify-2.2.0}/tests/test_kaggle_link.py +56 -0
  18. {letify-2.1.3 → letify-2.2.0}/tests/test_parallel_placement.py +39 -5
  19. {letify-2.1.3 → letify-2.2.0}/tests/test_runtime.py +37 -0
  20. {letify-2.1.3 → letify-2.2.0}/tests/test_transport.py +25 -0
  21. {letify-2.1.3 → letify-2.2.0}/.github/workflows/ci.yml +0 -0
  22. {letify-2.1.3 → letify-2.2.0}/.github/workflows/docs.yml +0 -0
  23. {letify-2.1.3 → letify-2.2.0}/.github/workflows/publish.yml +0 -0
  24. {letify-2.1.3 → letify-2.2.0}/.gitignore +0 -0
  25. {letify-2.1.3 → letify-2.2.0}/.researchtree +0 -0
  26. {letify-2.1.3 → letify-2.2.0}/LICENSE +0 -0
  27. {letify-2.1.3 → letify-2.2.0}/README.md +0 -0
  28. {letify-2.1.3 → letify-2.2.0}/docs/guide/01-getting-started.md +0 -0
  29. {letify-2.1.3 → letify-2.2.0}/docs/guide/02-providers.md +0 -0
  30. {letify-2.1.3 → letify-2.2.0}/docs/guide/03-execution-modes.md +0 -0
  31. {letify-2.1.3 → letify-2.2.0}/docs/guide/04-environments-and-data.md +0 -0
  32. {letify-2.1.3 → letify-2.2.0}/docs/guide/05-concurrency.md +0 -0
  33. {letify-2.1.3 → letify-2.2.0}/docs/guide/06-cost.md +0 -0
  34. {letify-2.1.3 → letify-2.2.0}/docs/guide/07-troubleshooting.md +0 -0
  35. {letify-2.1.3 → letify-2.2.0}/docs/guide/08-releasing.md +0 -0
  36. {letify-2.1.3 → letify-2.2.0}/docs/guide/README.md +0 -0
  37. {letify-2.1.3 → letify-2.2.0}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  38. {letify-2.1.3 → letify-2.2.0}/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.1.3 → letify-2.2.0}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  40. {letify-2.1.3 → letify-2.2.0}/docs/guide/ko/04-/355/231/230/352/262/275/352/263/274-/353/215/260/354/235/264/355/204/260.md" +0 -0
  41. {letify-2.1.3 → letify-2.2.0}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  42. {letify-2.1.3 → letify-2.2.0}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  43. {letify-2.1.3 → letify-2.2.0}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  44. {letify-2.1.3 → letify-2.2.0}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  45. {letify-2.1.3 → letify-2.2.0}/docs/guide/ko/README.md +0 -0
  46. {letify-2.1.3 → letify-2.2.0}/docs/locales/README_ko.md +0 -0
  47. {letify-2.1.3 → letify-2.2.0}/docs/mkdocs.yml +0 -0
  48. {letify-2.1.3 → letify-2.2.0}/examples/README.md +0 -0
  49. {letify-2.1.3 → letify-2.2.0}/examples/nvfp4-lora/.letify.example +0 -0
  50. {letify-2.1.3 → letify-2.2.0}/examples/nvfp4-lora/00_smoke.py +0 -0
  51. {letify-2.1.3 → letify-2.2.0}/examples/nvfp4-lora/01_sweep.py +0 -0
  52. {letify-2.1.3 → letify-2.2.0}/examples/nvfp4-lora/02_resume.py +0 -0
  53. {letify-2.1.3 → letify-2.2.0}/examples/nvfp4-lora/03_watch.py +0 -0
  54. {letify-2.1.3 → letify-2.2.0}/examples/nvfp4-lora/README.md +0 -0
  55. {letify-2.1.3 → letify-2.2.0}/examples/nvfp4-lora/README_ko.md +0 -0
  56. {letify-2.1.3 → letify-2.2.0}/examples/nvfp4-lora/common.py +0 -0
  57. {letify-2.1.3 → letify-2.2.0}/examples/nvfp4-lora/recipe.py +0 -0
  58. {letify-2.1.3 → letify-2.2.0}/letify/cli.py +0 -0
  59. {letify-2.1.3 → letify-2.2.0}/letify/config/__init__.py +0 -0
  60. {letify-2.1.3 → letify-2.2.0}/letify/config/inventory.py +0 -0
  61. {letify-2.1.3 → letify-2.2.0}/letify/config/login.py +0 -0
  62. {letify-2.1.3 → letify-2.2.0}/letify/config/schema.py +0 -0
  63. {letify-2.1.3 → letify-2.2.0}/letify/config/secrets.py +0 -0
  64. {letify-2.1.3 → letify-2.2.0}/letify/config/writer.py +0 -0
  65. {letify-2.1.3 → letify-2.2.0}/letify/declare/__init__.py +0 -0
  66. {letify-2.1.3 → letify-2.2.0}/letify/declare/cache.py +0 -0
  67. {letify-2.1.3 → letify-2.2.0}/letify/declare/env.py +0 -0
  68. {letify-2.1.3 → letify-2.2.0}/letify/declare/function.py +0 -0
  69. {letify-2.1.3 → letify-2.2.0}/letify/declare/instance.py +0 -0
  70. {letify-2.1.3 → letify-2.2.0}/letify/errors.py +0 -0
  71. {letify-2.1.3 → letify-2.2.0}/letify/install.py +0 -0
  72. {letify-2.1.3 → letify-2.2.0}/letify/launcher.py +0 -0
  73. {letify-2.1.3 → letify-2.2.0}/letify/paths.py +0 -0
  74. {letify-2.1.3 → letify-2.2.0}/letify/protocol/__init__.py +0 -0
  75. {letify-2.1.3 → letify-2.2.0}/letify/protocol/codec.py +0 -0
  76. {letify-2.1.3 → letify-2.2.0}/letify/protocol/driver.py +0 -0
  77. {letify-2.1.3 → letify-2.2.0}/letify/protocol/handle.py +0 -0
  78. {letify-2.1.3 → letify-2.2.0}/letify/protocol/wire.py +0 -0
  79. {letify-2.1.3 → letify-2.2.0}/letify/providers/__init__.py +0 -0
  80. {letify-2.1.3 → letify-2.2.0}/letify/providers/colab.py +0 -0
  81. {letify-2.1.3 → letify-2.2.0}/letify/providers/colab_files.py +0 -0
  82. {letify-2.1.3 → letify-2.2.0}/letify/providers/colab_keepalive.py +0 -0
  83. {letify-2.1.3 → letify-2.2.0}/letify/providers/elice.py +0 -0
  84. {letify-2.1.3 → letify-2.2.0}/letify/providers/kaggle_adapter.py +0 -0
  85. {letify-2.1.3 → letify-2.2.0}/letify/providers/local.py +0 -0
  86. {letify-2.1.3 → letify-2.2.0}/letify/providers/modal.py +0 -0
  87. {letify-2.1.3 → letify-2.2.0}/letify/providers/modal_adapter.py +0 -0
  88. {letify-2.1.3 → letify-2.2.0}/letify/providers/naming.py +0 -0
  89. {letify-2.1.3 → letify-2.2.0}/letify/providers/shell.py +0 -0
  90. {letify-2.1.3 → letify-2.2.0}/letify/providers/tunnel.py +0 -0
  91. {letify-2.1.3 → letify-2.2.0}/letify/providers/usage.py +0 -0
  92. {letify-2.1.3 → letify-2.2.0}/letify/remoting/__init__.py +0 -0
  93. {letify-2.1.3 → letify-2.2.0}/letify/remoting/capability.py +0 -0
  94. {letify-2.1.3 → letify-2.2.0}/letify/remoting/device/__init__.py +0 -0
  95. {letify-2.1.3 → letify-2.2.0}/letify/remoting/device/client.py +0 -0
  96. {letify-2.1.3 → letify-2.2.0}/letify/remoting/device/cuda.py +0 -0
  97. {letify-2.1.3 → letify-2.2.0}/letify/remoting/device/executor.py +0 -0
  98. {letify-2.1.3 → letify-2.2.0}/letify/remoting/device/frames.py +0 -0
  99. {letify-2.1.3 → letify-2.2.0}/letify/remoting/device/guard.py +0 -0
  100. {letify-2.1.3 → letify-2.2.0}/letify/remoting/device/tensor.py +0 -0
  101. {letify-2.1.3 → letify-2.2.0}/letify/remoting/device/trace.py +0 -0
  102. {letify-2.1.3 → letify-2.2.0}/letify/remoting/device/value.py +0 -0
  103. {letify-2.1.3 → letify-2.2.0}/letify/remoting/loader.py +0 -0
  104. {letify-2.1.3 → letify-2.2.0}/letify/remoting/probe.py +0 -0
  105. {letify-2.1.3 → letify-2.2.0}/letify/render.py +0 -0
  106. {letify-2.1.3 → letify-2.2.0}/letify/runtime/__init__.py +0 -0
  107. {letify-2.1.3 → letify-2.2.0}/letify/runtime/channel.py +0 -0
  108. {letify-2.1.3 → letify-2.2.0}/letify/runtime/lease.py +0 -0
  109. {letify-2.1.3 → letify-2.2.0}/letify/runtime/pool.py +0 -0
  110. {letify-2.1.3 → letify-2.2.0}/letify/runtime/telemetry.py +0 -0
  111. {letify-2.1.3 → letify-2.2.0}/letify/store/__init__.py +0 -0
  112. {letify-2.1.3 → letify-2.2.0}/letify/store/backends/__init__.py +0 -0
  113. {letify-2.1.3 → letify-2.2.0}/letify/store/backends/filesystem.py +0 -0
  114. {letify-2.1.3 → letify-2.2.0}/letify/store/backends/google_auth.py +0 -0
  115. {letify-2.1.3 → letify-2.2.0}/letify/store/backends/layout.py +0 -0
  116. {letify-2.1.3 → letify-2.2.0}/letify/store/backends/objects.py +0 -0
  117. {letify-2.1.3 → letify-2.2.0}/letify/store/cas.py +0 -0
  118. {letify-2.1.3 → letify-2.2.0}/letify/store/sendorder.py +0 -0
  119. {letify-2.1.3 → letify-2.2.0}/letify/store/volume.py +0 -0
  120. {letify-2.1.3 → letify-2.2.0}/letify/stubs.py +0 -0
  121. {letify-2.1.3 → letify-2.2.0}/letify/tools.py +0 -0
  122. {letify-2.1.3 → letify-2.2.0}/letify/transport/__init__.py +0 -0
  123. {letify-2.1.3 → letify-2.2.0}/letify/transport/agent.py +0 -0
  124. {letify-2.1.3 → letify-2.2.0}/letify/transport/announce.py +0 -0
  125. {letify-2.1.3 → letify-2.2.0}/letify/transport/nat.py +0 -0
  126. {letify-2.1.3 → letify-2.2.0}/letify/transport/portmap.py +0 -0
  127. {letify-2.1.3 → letify-2.2.0}/letify/transport/probe.py +0 -0
  128. {letify-2.1.3 → letify-2.2.0}/letify/transport/quic.py +0 -0
  129. {letify-2.1.3 → letify-2.2.0}/letify/transport/rendezvous.py +0 -0
  130. {letify-2.1.3 → letify-2.2.0}/letify/transport/setup.py +0 -0
  131. {letify-2.1.3 → letify-2.2.0}/letify/transport/sshopts.py +0 -0
  132. {letify-2.1.3 → letify-2.2.0}/letify/transport/strategies.py +0 -0
  133. {letify-2.1.3 → letify-2.2.0}/letify-core/.gitignore +0 -0
  134. {letify-2.1.3 → letify-2.2.0}/letify-core/build.py +0 -0
  135. {letify-2.1.3 → letify-2.2.0}/letify-core/letify-agent/Cargo.toml +0 -0
  136. {letify-2.1.3 → letify-2.2.0}/letify-core/letify-agent/src/driver.rs +0 -0
  137. {letify-2.1.3 → letify-2.2.0}/letify-core/letify-agent/src/main.rs +0 -0
  138. {letify-2.1.3 → letify-2.2.0}/letify-core/letify-driver/Cargo.toml +0 -0
  139. {letify-2.1.3 → letify-2.2.0}/letify-core/letify-driver/src/client.rs +0 -0
  140. {letify-2.1.3 → letify-2.2.0}/letify-core/letify-driver/src/exports.rs +0 -0
  141. {letify-2.1.3 → letify-2.2.0}/letify-core/letify-driver/src/image.rs +0 -0
  142. {letify-2.1.3 → letify-2.2.0}/letify-core/letify-driver/src/lib.rs +0 -0
  143. {letify-2.1.3 → letify-2.2.0}/letify-core/letify-driver/src/status.rs +0 -0
  144. {letify-2.1.3 → letify-2.2.0}/letify-core/letify-driver/src/table.rs +0 -0
  145. {letify-2.1.3 → letify-2.2.0}/letify-core/letify-quic/Cargo.toml +0 -0
  146. {letify-2.1.3 → letify-2.2.0}/letify-core/letify-quic/src/main.rs +0 -0
  147. {letify-2.1.3 → letify-2.2.0}/letify-core/letify-wire/Cargo.toml +0 -0
  148. {letify-2.1.3 → letify-2.2.0}/letify-core/letify-wire/src/lib.rs +0 -0
  149. {letify-2.1.3 → letify-2.2.0}/letify-core/letify-wire/tests/throughput.rs +0 -0
  150. {letify-2.1.3 → letify-2.2.0}/letify-core/rust-toolchain.toml +0 -0
  151. {letify-2.1.3 → letify-2.2.0}/letify-ext/.vscodeignore +0 -0
  152. {letify-2.1.3 → letify-2.2.0}/letify-ext/LICENSE +0 -0
  153. {letify-2.1.3 → letify-2.2.0}/letify-ext/README.md +0 -0
  154. {letify-2.1.3 → letify-2.2.0}/letify-ext/media/icon.svg +0 -0
  155. {letify-2.1.3 → letify-2.2.0}/letify-ext/src/cli.ts +0 -0
  156. {letify-2.1.3 → letify-2.2.0}/letify-ext/src/extension.ts +0 -0
  157. {letify-2.1.3 → letify-2.2.0}/letify-ext/src/history.ts +0 -0
  158. {letify-2.1.3 → letify-2.2.0}/letify-ext/src/model.ts +0 -0
  159. {letify-2.1.3 → letify-2.2.0}/letify-ext/src/poll.ts +0 -0
  160. {letify-2.1.3 → letify-2.2.0}/letify-ext/src/render.ts +0 -0
  161. {letify-2.1.3 → letify-2.2.0}/letify-ext/test/cli.test.ts +0 -0
  162. {letify-2.1.3 → letify-2.2.0}/letify-ext/test/manifest.test.ts +0 -0
  163. {letify-2.1.3 → letify-2.2.0}/letify-ext/test/model.test.ts +0 -0
  164. {letify-2.1.3 → letify-2.2.0}/letify-ext/test/regressions.test.ts +0 -0
  165. {letify-2.1.3 → letify-2.2.0}/letify-ext/tsconfig.json +0 -0
  166. {letify-2.1.3 → letify-2.2.0}/letify_providers.py +0 -0
  167. {letify-2.1.3 → letify-2.2.0}/scripts/sync-main.sh +0 -0
  168. {letify-2.1.3 → letify-2.2.0}/scripts/version.py +0 -0
  169. {letify-2.1.3 → letify-2.2.0}/tests/conftest.py +0 -0
  170. {letify-2.1.3 → letify-2.2.0}/tests/fake_kaggle_adapter.py +0 -0
  171. {letify-2.1.3 → letify-2.2.0}/tests/fake_modal_adapter.py +0 -0
  172. {letify-2.1.3 → letify-2.2.0}/tests/test_cli.py +0 -0
  173. {letify-2.1.3 → letify-2.2.0}/tests/test_cli_output.py +0 -0
  174. {letify-2.1.3 → letify-2.2.0}/tests/test_colab_keepalive.py +0 -0
  175. {letify-2.1.3 → letify-2.2.0}/tests/test_config.py +0 -0
  176. {letify-2.1.3 → letify-2.2.0}/tests/test_data.py +0 -0
  177. {letify-2.1.3 → letify-2.2.0}/tests/test_data_streaming.py +0 -0
  178. {letify-2.1.3 → letify-2.2.0}/tests/test_declare.py +0 -0
  179. {letify-2.1.3 → letify-2.2.0}/tests/test_device.py +0 -0
  180. {letify-2.1.3 → letify-2.2.0}/tests/test_editor_sessions.py +0 -0
  181. {letify-2.1.3 → letify-2.2.0}/tests/test_elice.py +0 -0
  182. {letify-2.1.3 → letify-2.2.0}/tests/test_frames.py +0 -0
  183. {letify-2.1.3 → letify-2.2.0}/tests/test_install.py +0 -0
  184. {letify-2.1.3 → letify-2.2.0}/tests/test_kaggle.py +0 -0
  185. {letify-2.1.3 → letify-2.2.0}/tests/test_kaggle_cookie.py +0 -0
  186. {letify-2.1.3 → letify-2.2.0}/tests/test_link_log.py +0 -0
  187. {letify-2.1.3 → letify-2.2.0}/tests/test_notebook.py +0 -0
  188. {letify-2.1.3 → letify-2.2.0}/tests/test_paths.py +0 -0
  189. {letify-2.1.3 → letify-2.2.0}/tests/test_portmap.py +0 -0
  190. {letify-2.1.3 → letify-2.2.0}/tests/test_protocol.py +0 -0
  191. {letify-2.1.3 → letify-2.2.0}/tests/test_providers.py +0 -0
  192. {letify-2.1.3 → letify-2.2.0}/tests/test_quic.py +0 -0
  193. {letify-2.1.3 → letify-2.2.0}/tests/test_remoting.py +0 -0
  194. {letify-2.1.3 → letify-2.2.0}/tests/test_render.py +0 -0
  195. {letify-2.1.3 → letify-2.2.0}/tests/test_ship.py +0 -0
  196. {letify-2.1.3 → letify-2.2.0}/tests/test_store.py +0 -0
  197. {letify-2.1.3 → letify-2.2.0}/tests/test_strategies.py +0 -0
  198. {letify-2.1.3 → letify-2.2.0}/tests/test_stubs.py +0 -0
  199. {letify-2.1.3 → letify-2.2.0}/tests/test_tunnel_setup.py +0 -0
  200. {letify-2.1.3 → letify-2.2.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.1.3
3
+ Version: 2.2.0
4
4
  Summary: Declarations that become infrastructure.
5
5
  Project-URL: Homepage, https://github.com/DarkPyonix/letify
6
6
  Project-URL: Documentation, https://github.com/DarkPyonix/letify/tree/main/docs
@@ -62,7 +62,7 @@ remote: Final = Host.remote
62
62
  # The enum class stays importable from letify.declare.instance for letify's own use.
63
63
  del Host
64
64
 
65
- __version__ = "2.1.3"
65
+ __version__ = "2.2.0"
66
66
 
67
67
 
68
68
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -64,6 +64,21 @@ widen_pipe(_FRAME_FD)
64
64
  _SENDER = (TextSender if _LETIFY_TEXT_FRAMES else Sender)(fd_writer(_FRAME_FD))
65
65
 
66
66
 
67
+ #: Written to a captured descriptor to find out where its reader has got to. A pipe is
68
+ #: read in order, so every byte written before this has been framed once it is reached.
69
+ #: It is never forwarded. Spec "Worker output".
70
+ _DRAIN_TOKEN = b"\x1a\x1aLETIFY-DRAIN-7f3a9c4e\x1a\x1a"
71
+
72
+
73
+ def _held_back(data):
74
+ """How many bytes at the end of ``data`` could be the start of a split token."""
75
+ most = min(len(_DRAIN_TOKEN) - 1, len(data))
76
+ for size in range(most, 0, -1):
77
+ if _DRAIN_TOKEN.startswith(data[len(data) - size:]):
78
+ return size
79
+ return 0
80
+
81
+
67
82
  class _Pump(threading.Thread):
68
83
  """Reads one captured descriptor and sends what it reads as output frames."""
69
84
 
@@ -71,10 +86,23 @@ class _Pump(threading.Thread):
71
86
  threading.Thread.__init__(self, daemon=True)
72
87
  self.kind = kind
73
88
  self.busy = False
89
+ self.fd = fd
90
+ #: Bytes withheld because they may be the start of a token split across two reads.
91
+ self.tail = b""
92
+ self.reached = threading.Event()
74
93
  self.read_fd, write_fd = os.pipe()
75
94
  os.dup2(write_fd, fd)
76
95
  os.close(write_fd)
77
96
 
97
+ def send(self, data):
98
+ if not data:
99
+ return
100
+ self.busy = True
101
+ try:
102
+ _SENDER.frame(self.kind, 0, data)
103
+ finally:
104
+ self.busy = False
105
+
78
106
  def run(self):
79
107
  while True:
80
108
  try:
@@ -83,16 +111,48 @@ class _Pump(threading.Thread):
83
111
  break
84
112
  if not data:
85
113
  break
86
- self.busy = True
114
+ data = self.tail + data
115
+ self.tail = b""
87
116
  try:
88
- _SENDER.frame(self.kind, 0, data)
117
+ while True:
118
+ at = data.find(_DRAIN_TOKEN)
119
+ if at < 0:
120
+ break
121
+ self.send(data[:at])
122
+ data = data[at + len(_DRAIN_TOKEN):]
123
+ self.reached.set()
124
+ keep = _held_back(data)
125
+ if keep:
126
+ self.tail = data[len(data) - keep:]
127
+ data = data[: len(data) - keep]
128
+ self.send(data)
89
129
  except OSError:
90
130
  break
91
- finally:
92
- self.busy = False
93
131
  os.close(self.read_fd)
94
132
 
95
133
 
134
+ def _drain_output(timeout=2.0):
135
+ """Wait until everything written to the captured descriptors has been framed.
136
+
137
+ Spec "Worker output": a call's reply waits for the output its body made, so the
138
+ caller's own next print comes after it. Waiting on the reading thread's state is not
139
+ enough, because it has read bytes it has not yet framed; the token goes through the
140
+ pipe behind those bytes instead. It gives up rather than lose a result.
141
+ """
142
+ for stream in (sys.stdout, sys.stderr):
143
+ try:
144
+ stream.flush()
145
+ except Exception:
146
+ pass
147
+ for pump in _PUMPS:
148
+ pump.reached.clear()
149
+ try:
150
+ os.write(pump.fd, _DRAIN_TOKEN)
151
+ except OSError:
152
+ continue
153
+ pump.reached.wait(timeout)
154
+
155
+
96
156
  _PUMPS = [_Pump(1, STDOUT), _Pump(2, STDERR)]
97
157
  for _pump in _PUMPS:
98
158
  _pump.start()
@@ -2066,6 +2126,9 @@ def _run(stream, request, settle):
2066
2126
  request = None
2067
2127
  if settle:
2068
2128
  _settle()
2129
+ if name == "call":
2130
+ # Spec "Worker output": the body's output is framed before its result goes out.
2131
+ _drain_output()
2069
2132
  _reply(stream, outcome)
2070
2133
 
2071
2134
 
@@ -273,6 +273,17 @@ class Provider(abc.ABC):
273
273
  value = self.config.option("transfer_streams")
274
274
  return max(1, int(value)) if isinstance(value, (int, float)) else STREAMS
275
275
 
276
+ @property
277
+ def transfer_connection_cost_s(self) -> float:
278
+ """Seconds one more transfer connection takes to open.
279
+
280
+ Spec "Several connections at once": zero where the provider dials an address, so
281
+ the extra connections are free and only the round trip decides. A provider reached
282
+ by a hole punch overrides it, because each stream punches again.
283
+ """
284
+ value = self.config.option("transfer_connection_cost_s")
285
+ return max(0.0, float(value)) if isinstance(value, (int, float)) else 0.0
286
+
276
287
  @property
277
288
  def transfer_parallel_rtt_ms(self) -> int:
278
289
  """The round trip above which a blob is split, from ``transfer_parallel_rtt_ms``.
@@ -1207,6 +1207,45 @@ class Kaggle(Provider):
1207
1207
  name=name,
1208
1208
  )
1209
1209
 
1210
+ @property
1211
+ def transfer_connection_cost_s(self) -> float:
1212
+ """A punch, which is what one more stream costs here. Measured at about 10 s."""
1213
+ value = self.config.option("transfer_connection_cost_s")
1214
+ return max(0.0, float(value)) if isinstance(value, (int, float)) else 10.0
1215
+
1216
+ def link(self, runtime: Runtime | None = None) -> Any:
1217
+ """The link carrying this runtime, or None when the bridge is carrying it.
1218
+
1219
+ The placement reads ``rtt_ms`` from it to decide how many streams to use.
1220
+ """
1221
+ name = getattr(runtime, "name", None)
1222
+ return self._links.get(name) if name else None
1223
+
1224
+ def transfer_channel(self, runtime: Runtime | None) -> Channel:
1225
+ """One more stream for a placement, on a connection of its own.
1226
+
1227
+ Spec "Several connections at once": the link is asked for its own SSH command,
1228
+ which lands on a local forwarding port of its own and so punches again. Reusing
1229
+ the session's command would multiplex onto the one connection instead, and
1230
+ multiplexed channels do not add up.
1231
+ """
1232
+ import shlex as _shlex
1233
+
1234
+ from ..protocol.worker import BOOTSTRAP
1235
+ from ..runtime.channel import PersistentChannel
1236
+
1237
+ name = getattr(runtime, "name", None)
1238
+ link = self._links.get(name) if name else None
1239
+ if link is None:
1240
+ raise UnsupportedMode(
1241
+ f"{self.alias} is carried by the kernel bridge for {name}, which is one "
1242
+ f"connection and offers no second stream. A punched session does."
1243
+ )
1244
+ return PersistentChannel(
1245
+ link.ssh_command(f"{self.remote_python} -u -c {_shlex.quote(BOOTSTRAP)}"),
1246
+ name=f"{self.alias}-transfer",
1247
+ )
1248
+
1210
1249
  def open_channel(self, runtime: Runtime) -> Channel:
1211
1250
  """Start a session from the cookie and open one worker in one cell of it.
1212
1251
 
@@ -130,6 +130,16 @@ def project_dir(workspace_root: str, env: Env) -> str:
130
130
  return f"{workspace_root.rstrip('/')}/project/{env.key}"
131
131
 
132
132
 
133
+ def packages_in(files: dict[str, bytes]) -> int:
134
+ """How many packages the lock names, for the line the environment build prints.
135
+
136
+ The lock is already read, so counting its ``[[package]]`` entries costs nothing and
137
+ tells the reader whether to expect seconds or minutes. Spec "Environment".
138
+ """
139
+ lock = files.get("uv.lock") or b""
140
+ return lock.count(b"[[package]]")
141
+
142
+
133
143
  def project_files(env: Env) -> dict[str, bytes]:
134
144
  """Read the files a runtime syncs from, refusing what cannot give a matching interpreter.
135
145
 
@@ -613,6 +613,19 @@ class Runtime:
613
613
 
614
614
  return (environment_volume(self.provider),)
615
615
 
616
+ def _say(self, message: str) -> None:
617
+ """One decision line about this runtime, as the transport's lines are written.
618
+
619
+ Spec "Environment": the build says it is happening. Silent when the launcher was
620
+ built with ``announce=False``.
621
+ """
622
+ from ..transport.announce import printer
623
+
624
+ # On the instance dict, not getattr: a provider's __getattr__ answers accelerator
625
+ # names and raises UnknownInstance for anything else, which no default catches.
626
+ announce = self.provider.__dict__.get("announce", True)
627
+ printer(bool(announce))(f"{self.name}: {message}")
628
+
616
629
  def install_env(self) -> None:
617
630
  """Build the project's environment in the session and move the worker onto it.
618
631
 
@@ -641,6 +654,7 @@ class Runtime:
641
654
  digest = volume.cached_env(self.env, self.platform)
642
655
  if not digest:
643
656
  continue
657
+ self._say(f"restoring the environment, cached as {digest[:12]}")
644
658
  archive = f"{where['parent']}/.{digest}.tar.gz"
645
659
  volume.materialize(
646
660
  self, digest, path=archive, unpack=True, target=where["parent"], links=True
@@ -658,6 +672,13 @@ class Runtime:
658
672
  self.env, files, root=root, name=self.name, workspace=workspace,
659
673
  archive=bool(archives),
660
674
  )
675
+ # Spec "Environment": the longest step of a first session, and uv's own output
676
+ # is captured rather than streamed, so it says it is happening.
677
+ self._say(
678
+ f"building the environment, {bootstrap.packages_in(files)} packages "
679
+ f"from {self.env.lock}"
680
+ )
681
+ began = time.monotonic()
661
682
  try:
662
683
  self.eval(source, timeout=3600)
663
684
  except RemoteError as exc:
@@ -665,6 +686,7 @@ class Runtime:
665
686
  raise EnvironmentFailure(
666
687
  message.removeprefix("RuntimeError: "), stderr=exc.remote_traceback
667
688
  ) from exc
689
+ self._say(f"environment built in {time.monotonic() - began:.1f} s")
668
690
  self.env_source = "sync"
669
691
  self.python = where["python"]
670
692
  self.channel.switch_interpreter(where["python"])
@@ -801,19 +801,38 @@ class Stream:
801
801
  )
802
802
 
803
803
 
804
- def streams_for(*, streams: int, threshold_mib: int, rtt_ms: float | None, size: int) -> int:
804
+ def streams_for(
805
+ *,
806
+ streams: int,
807
+ threshold_mib: int,
808
+ rtt_ms: float | None,
809
+ size: int,
810
+ rate_bps: float | None = None,
811
+ cost_s: float = 0.0,
812
+ ) -> int:
805
813
  """How many connections this blob is worth splitting across.
806
814
 
807
815
  Spec "Several connections at once": one below the size threshold, because opening
808
816
  connections to send a few megabytes costs more than it saves; one on a short link,
809
- because there a single stream already carries everything the link will carry; and
810
- never more streams than there are chunks to send.
817
+ because there a single stream already carries everything the link will carry; never
818
+ more streams than there are chunks to send; and, when a connection costs something to
819
+ open, only as many as finish the transfer sooner than one would.
811
820
  """
812
821
  if streams <= 1 or size < threshold_mib * (1 << 20):
813
822
  return 1
814
823
  if rtt_ms is None or rtt_ms < PARALLEL_RTT_MS:
815
824
  return 1
816
- return max(1, min(streams, (size + CHUNK - 1) // CHUNK))
825
+ count = max(1, min(streams, (size + CHUNK - 1) // CHUNK))
826
+ if cost_s > 0 and count > 1:
827
+ if not rate_bps or rate_bps <= 0:
828
+ # Nothing to weigh the cost against, and the cost is real, so do not pay it.
829
+ return 1
830
+ alone = size / rate_bps
831
+ # The connections are opened at the same time, so the cost is paid once.
832
+ together = size / (rate_bps * count) + cost_s
833
+ if together >= alone:
834
+ return 1
835
+ return count
817
836
 
818
837
 
819
838
  def _streams_for(runtime: Any, size: int) -> int:
@@ -832,9 +851,16 @@ def _streams_for(runtime: Any, size: int) -> int:
832
851
  rtt = getattr(link, "rtt_ms", None)
833
852
  except Exception:
834
853
  rtt = None
854
+ rate = None
855
+ try:
856
+ rate = getattr(link, "upload_bps", None)
857
+ except Exception:
858
+ rate = None
835
859
  return streams_for(
836
860
  streams=int(getattr(provider, "transfer_streams", STREAMS) or 1),
837
861
  threshold_mib=int(getattr(provider, "transfer_parallel_mib", PARALLEL_MIB) or 0),
862
+ rate_bps=rate,
863
+ cost_s=float(getattr(provider, "transfer_connection_cost_s", 0.0) or 0.0),
838
864
  rtt_ms=rtt,
839
865
  size=size,
840
866
  )
@@ -34,6 +34,9 @@ class Link:
34
34
  self.rank = rank
35
35
  #: The round trip the pipeline measured over this link, when it measured one.
36
36
  self.rtt_ms: float | None = None
37
+ #: The probe's upload rate, which the placement weighs a connection's cost
38
+ #: against. Spec "Several connections at once".
39
+ self.upload_bps: float | None = None
37
40
 
38
41
  def probe_stream(self) -> object | None:
39
42
  """A stream the probe can run over, or None when this link cannot carry it."""
@@ -209,6 +209,19 @@ def _below(pairs: Sequence[tuple[str, float, float]], ratio: float, against: str
209
209
  ]
210
210
 
211
211
 
212
+ def _measured_on(link: Any, measured: ProbeResult | None) -> Any:
213
+ """Record on the link what the probe measured, and return the link.
214
+
215
+ Every path out of ``connect`` goes through this, because the placement reads
216
+ ``rtt_ms`` to decide how many connections to use and a link with none keeps a single
217
+ stream. Spec "Several connections at once".
218
+ """
219
+ if measured is not None:
220
+ link.rtt_ms = measured.rtt_ms
221
+ link.upload_bps = measured.upload_bps
222
+ return link
223
+
224
+
212
225
  def _close(link: Any) -> None:
213
226
  try:
214
227
  link.close()
@@ -308,7 +321,7 @@ class Pipeline:
308
321
  [*reasons, f"{link.strategy}: below the floor: {', '.join(floored)}"]
309
322
  ),
310
323
  )
311
- return link
324
+ return _measured_on(link, measured)
312
325
 
313
326
  network = self.fingerprint() if self.cache is not None else None
314
327
  if self.cache is not None and network is not None:
@@ -324,9 +337,9 @@ class Pipeline:
324
337
  f"{skipped_note}{held_note}"
325
338
  )
326
339
  link, measured = self._race(applicable, reasons)
327
- if measured is not None:
328
- # Kept on the link so status can say how far away the machine is.
329
- link.rtt_ms = measured.rtt_ms
340
+ # Kept on the link so status can say how far away the machine is, and so the
341
+ # placement knows whether splitting a blob across connections is worth it.
342
+ _measured_on(link, measured)
330
343
  if self.cache is not None and network is not None and measured is not None:
331
344
  self.cache.save(link.strategy, measured, network)
332
345
  self._say(f"cache rewritten: {link.strategy}")
@@ -394,7 +407,7 @@ class Pipeline:
394
407
  _close(link)
395
408
  return None
396
409
  self._say(f"cached {name} accepted")
397
- return link
410
+ return _measured_on(link, measured)
398
411
 
399
412
  # -- the race ------------------------------------------------------------
400
413
 
@@ -138,7 +138,7 @@ dependencies = [
138
138
 
139
139
  [[package]]
140
140
  name = "letify-agent"
141
- version = "2.1.3"
141
+ version = "2.2.0"
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.1.3"
149
+ version = "2.2.0"
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.1.3"
167
+ version = "2.2.0"
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.1.3"
11
+ version = "2.2.0"
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.1.3",
3
+ "version": "2.2.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "letify-status",
9
- "version": "2.1.3",
9
+ "version": "2.2.0",
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.1.3",
5
+ "version": "2.2.0",
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.1.3"
3
+ version = "2.2.0"
4
4
  description = "Declarations that become infrastructure."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -553,3 +553,55 @@ def test_status_reports_the_inventory_against_what_is_reserved(launcher_from) ->
553
553
  let = launcher_from('[lab]\nkind = "local"\n[lab.devices]\nCPU = { count = 2 }\n')
554
554
  report = let.status()
555
555
  assert report["devices"]["lab"]["CPU"] == {"count": 2, "reserved": 0, "indices": []}
556
+
557
+
558
+ def test_the_output_of_a_call_comes_before_what_the_caller_prints_next(let, cpu, capsys) -> None:
559
+ """Spec "Worker output": a call's reply waits for the output the body made.
560
+
561
+ The worker frames output on a thread of its own, so the reply could overtake it and
562
+ the caller's next print would land first, which is what a terminal shows out of order.
563
+ A live CI run failed the weaker form of this with `assert 'epoch 1 loss 0.5' in ''`.
564
+ """
565
+
566
+ @let.function(device=cpu, host=letify.remote)
567
+ def noisy() -> int:
568
+ for index in range(200):
569
+ print(f"line {index} of the body")
570
+ return 7
571
+
572
+ assert noisy() == 7
573
+ print("the caller speaks after the call")
574
+ out = capsys.readouterr().out
575
+ assert "line 0 of the body" in out
576
+ assert "line 199 of the body" in out
577
+ assert out.index("line 199 of the body") < out.index("the caller speaks after the call")
578
+
579
+
580
+ def test_output_that_looks_like_the_drain_token_still_arrives(let, cpu, capsys) -> None:
581
+ """Spec "Worker output": the token is never forwarded, and output is never eaten.
582
+
583
+ The reader withholds a tail that could be the start of a split token, so a body whose
584
+ own output ends in that prefix must still see all of it arrive.
585
+ """
586
+ import re
587
+
588
+ from letify.protocol import worker
589
+
590
+ found = re.search(r'_DRAIN_TOKEN = b"([^"]+)"', worker.SOURCE)
591
+ assert found, "the worker no longer names a drain token"
592
+ token = found.group(1).encode().decode("unicode_escape")
593
+ prefix = token[: len(token) - 1]
594
+
595
+ @let.function(device=cpu, host=letify.remote)
596
+ def speaks(text: str) -> int:
597
+ import sys
598
+
599
+ sys.stdout.write("before|" + text)
600
+ sys.stdout.flush()
601
+ sys.stdout.write("|after\n")
602
+ return len(text)
603
+
604
+ assert speaks(prefix) == len(prefix)
605
+ out = capsys.readouterr().out
606
+ assert "before|" in out and "|after" in out
607
+ assert token not in out
@@ -6,8 +6,10 @@ the strategies of Choosing a link are raced over it.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import pytest
9
10
  from conftest import provider_of
10
11
 
12
+ from letify.errors import UnsupportedMode
11
13
  from letify.providers.kaggle import Kaggle
12
14
 
13
15
 
@@ -243,3 +245,57 @@ def test_a_kaggle_runtime_pins_its_host_key_under_its_own_alias() -> None:
243
245
  assert target.host_key_alias == "letify-kaggle_a-letify-cpu-abc123"
244
246
  command = target.forwarded_ssh(40000)
245
247
  assert command[command.index("HostKeyAlias=letify-kaggle_a-letify-cpu-abc123")]
248
+
249
+
250
+ def test_a_punched_kaggle_transfer_stream_asks_the_link_for_a_connection(monkeypatch) -> None:
251
+ """Spec "Several connections at once": each stream gets a connection of its own.
252
+
253
+ Multiplexed channels do not add up. Measured on one punched Kaggle link at a 193 ms
254
+ round trip, four concurrent `ssh cat > /dev/null`, each asking the link for its own
255
+ command, carried 6.10 MiB/s against 1.57 MiB/s for one, while four channels
256
+ multiplexed onto the session's command carried 1.14 times one.
257
+ """
258
+ built: list[list[str]] = []
259
+ asked: list[str] = []
260
+ provider = kaggle()
261
+
262
+ class Link:
263
+ """Each ssh_command lands on a port of its own, as a punched link's does."""
264
+
265
+ strategy = "tcp_punch"
266
+ rtt_ms = 138.0
267
+
268
+ def ssh_command(self, remote_command=None):
269
+ asked.append("session" if not asked else "transfer")
270
+ return ["ssh", "-p", str(40000 + len(asked)), "root@127.0.0.1",
271
+ remote_command or ""]
272
+
273
+ class Fake:
274
+ def __init__(self, command, name=None, **kwargs):
275
+ built.append(command)
276
+ self.name = name
277
+
278
+ class Runtime:
279
+ name = "k-1"
280
+
281
+ monkeypatch.setattr(provider, "link_over", lambda channel, name=None: Link())
282
+ monkeypatch.setattr("letify.runtime.channel.PersistentChannel", Fake)
283
+ provider.channel_over(object(), name="k-1")
284
+ assert provider.link(Runtime()).rtt_ms == 138.0
285
+
286
+ provider.transfer_channel(Runtime())
287
+ assert len(built) == 2
288
+ assert built[1] != built[0], "a transfer channel asks the link for a command of its own"
289
+ assert asked == ["session", "transfer"]
290
+
291
+
292
+ def test_a_kaggle_session_on_the_bridge_offers_no_transfer_channel() -> None:
293
+ """Spec: a session that never punched is carried by the bridge, which has no second channel."""
294
+ provider = kaggle()
295
+
296
+ class Runtime:
297
+ name = "never-punched"
298
+
299
+ assert provider.link(Runtime()) is None
300
+ with pytest.raises(UnsupportedMode):
301
+ provider.transfer_channel(Runtime())
@@ -304,14 +304,13 @@ def test_an_account_sets_the_round_trip_threshold() -> None:
304
304
 
305
305
  def test_a_provider_with_no_transfer_channel_keeps_one_stream() -> None:
306
306
  # Spec "Several connections at once": a provider whose channel is not a connection it
307
- # can open more of, such as Modal's sandbox pipes or Kaggle's kernel bridge, has no
308
- # transfer channel, and asking it for one would fail inside the placement.
309
- from letify.providers.kaggle import Kaggle
307
+ # can open more of, such as Modal's sandbox pipes, has no transfer channel, and asking
308
+ # it for one would fail inside the placement. Kaggle has one, because a session that
309
+ # punched is reached over SSH and the link can punch again for each stream.
310
310
  from letify.providers.modal import Modal
311
311
  from letify.store import pathdata
312
312
 
313
- for cls in (Modal, Kaggle):
314
- assert not hasattr(cls, "transfer_channel"), cls.__name__
313
+ assert not hasattr(Modal, "transfer_channel")
315
314
 
316
315
  class Bare:
317
316
  transfer_streams = 4
@@ -349,3 +348,38 @@ def test_the_guard_does_not_go_through_a_provider_lookup() -> None:
349
348
  provider = Picky()
350
349
 
351
350
  assert pathdata._streams_for(Runtime(), 256 << 20) == 1
351
+
352
+
353
+ def test_streams_that_cost_a_connection_are_opened_only_when_they_pay() -> None:
354
+ """Spec "Several connections at once": a connection that costs something pays for itself.
355
+
356
+ A Kaggle runtime at a 44.7 ms round trip carried 128 MiB at 21.0 MiB/s on one stream
357
+ and 9.5 MiB/s on four, because three more hole punches cost more than the transfer
358
+ saved.
359
+ """
360
+ from letify.store.pathdata import streams_for
361
+
362
+ MIB = 1 << 20
363
+ near = dict(streams=4, threshold_mib=64, rtt_ms=44.7, size=128 * MIB)
364
+ # Free connections: the near link still splits, as a forward SSH account does.
365
+ assert streams_for(**near, rate_bps=56.8 * MIB, cost_s=0.0) == 4
366
+ # A punch each: 128 MiB at 56.8 MiB/s takes 2.3 s, and one punch alone costs 10 s.
367
+ assert streams_for(**near, rate_bps=56.8 * MIB, cost_s=10.0) == 1
368
+ # Far and slow enough that the punch is worth it.
369
+ assert streams_for(
370
+ streams=4, threshold_mib=64, rtt_ms=193.0, size=1024 * MIB,
371
+ rate_bps=12.8 * MIB, cost_s=10.0,
372
+ ) == 4
373
+ # No rate to estimate with: the cost cannot be weighed, so one stream.
374
+ assert streams_for(**near, rate_bps=None, cost_s=10.0) == 1
375
+
376
+
377
+ def test_a_provider_reports_what_one_more_connection_costs() -> None:
378
+ """Spec: 0 for a provider that dials an address, about 10 for one reached by a punch."""
379
+ from conftest import provider_of
380
+
381
+ from letify.providers.kaggle import Kaggle
382
+ from letify.providers.shell import Shell
383
+
384
+ assert float(provider_of(Shell, "dept_gpu").transfer_connection_cost_s) == 0.0
385
+ assert float(provider_of(Kaggle, "kaggle_a").transfer_connection_cost_s) >= 5.0