letify 1.2.4__tar.gz → 2.0.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 (201) hide show
  1. {letify-1.2.4 → letify-2.0.0}/PKG-INFO +1 -1
  2. {letify-1.2.4 → letify-2.0.0}/letify/__init__.py +1 -1
  3. {letify-1.2.4 → letify-2.0.0}/letify/install.py +58 -0
  4. {letify-1.2.4 → letify-2.0.0}/letify/protocol/worker.py +76 -0
  5. {letify-1.2.4 → letify-2.0.0}/letify/providers/base.py +36 -0
  6. {letify-1.2.4 → letify-2.0.0}/letify/providers/colab.py +5 -18
  7. {letify-1.2.4 → letify-2.0.0}/letify/providers/kaggle.py +174 -1
  8. {letify-1.2.4 → letify-2.0.0}/letify/providers/local.py +14 -0
  9. {letify-1.2.4 → letify-2.0.0}/letify/providers/shell.py +66 -2
  10. {letify-1.2.4 → letify-2.0.0}/letify/store/pathdata.py +156 -1
  11. {letify-1.2.4 → letify-2.0.0}/letify/stubs.py +3 -2
  12. {letify-1.2.4 → letify-2.0.0}/letify/transport/nat.py +206 -14
  13. {letify-1.2.4 → letify-2.0.0}/letify/transport/pipeline.py +32 -0
  14. letify-2.0.0/letify/transport/portmap.py +323 -0
  15. letify-2.0.0/letify/transport/quic.py +60 -0
  16. {letify-1.2.4 → letify-2.0.0}/letify/transport/rendezvous.py +39 -9
  17. {letify-1.2.4 → letify-2.0.0}/letify/transport/strategies.py +166 -5
  18. letify-2.0.0/letify-core/Cargo.lock +749 -0
  19. {letify-1.2.4 → letify-2.0.0}/letify-core/Cargo.toml +2 -2
  20. {letify-1.2.4 → letify-2.0.0}/letify-core/build.py +13 -0
  21. letify-2.0.0/letify-core/letify-quic/Cargo.toml +20 -0
  22. letify-2.0.0/letify-core/letify-quic/src/main.rs +316 -0
  23. {letify-1.2.4 → letify-2.0.0}/letify-ext/package-lock.json +2 -2
  24. {letify-1.2.4 → letify-2.0.0}/letify-ext/package.json +1 -1
  25. {letify-1.2.4 → letify-2.0.0}/pyproject.toml +9 -1
  26. letify-2.0.0/tests/test_kaggle_link.py +232 -0
  27. {letify-1.2.4 → letify-2.0.0}/tests/test_link_log.py +32 -0
  28. letify-2.0.0/tests/test_parallel_placement.py +351 -0
  29. letify-2.0.0/tests/test_portmap.py +225 -0
  30. {letify-1.2.4 → letify-2.0.0}/tests/test_providers.py +32 -8
  31. letify-2.0.0/tests/test_quic.py +187 -0
  32. {letify-1.2.4 → letify-2.0.0}/tests/test_strategies.py +208 -5
  33. {letify-1.2.4 → letify-2.0.0}/tests/test_stubs.py +25 -0
  34. {letify-1.2.4 → letify-2.0.0}/tests/test_transport.py +175 -0
  35. letify-1.2.4/letify-core/Cargo.lock +0 -44
  36. {letify-1.2.4 → letify-2.0.0}/.github/workflows/ci.yml +0 -0
  37. {letify-1.2.4 → letify-2.0.0}/.github/workflows/docs.yml +0 -0
  38. {letify-1.2.4 → letify-2.0.0}/.github/workflows/publish.yml +0 -0
  39. {letify-1.2.4 → letify-2.0.0}/.gitignore +0 -0
  40. {letify-1.2.4 → letify-2.0.0}/.researchtree +0 -0
  41. {letify-1.2.4 → letify-2.0.0}/LICENSE +0 -0
  42. {letify-1.2.4 → letify-2.0.0}/README.md +0 -0
  43. {letify-1.2.4 → letify-2.0.0}/docs/guide/01-getting-started.md +0 -0
  44. {letify-1.2.4 → letify-2.0.0}/docs/guide/02-providers.md +0 -0
  45. {letify-1.2.4 → letify-2.0.0}/docs/guide/03-execution-modes.md +0 -0
  46. {letify-1.2.4 → letify-2.0.0}/docs/guide/04-environments-and-data.md +0 -0
  47. {letify-1.2.4 → letify-2.0.0}/docs/guide/05-concurrency.md +0 -0
  48. {letify-1.2.4 → letify-2.0.0}/docs/guide/06-cost.md +0 -0
  49. {letify-1.2.4 → letify-2.0.0}/docs/guide/07-troubleshooting.md +0 -0
  50. {letify-1.2.4 → letify-2.0.0}/docs/guide/08-releasing.md +0 -0
  51. {letify-1.2.4 → letify-2.0.0}/docs/guide/README.md +0 -0
  52. {letify-1.2.4 → letify-2.0.0}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  53. {letify-1.2.4 → letify-2.0.0}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  54. {letify-1.2.4 → letify-2.0.0}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  55. {letify-1.2.4 → letify-2.0.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
  56. {letify-1.2.4 → letify-2.0.0}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  57. {letify-1.2.4 → letify-2.0.0}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  58. {letify-1.2.4 → letify-2.0.0}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  59. {letify-1.2.4 → letify-2.0.0}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  60. {letify-1.2.4 → letify-2.0.0}/docs/guide/ko/README.md +0 -0
  61. {letify-1.2.4 → letify-2.0.0}/docs/locales/README_ko.md +0 -0
  62. {letify-1.2.4 → letify-2.0.0}/docs/mkdocs.yml +0 -0
  63. {letify-1.2.4 → letify-2.0.0}/examples/README.md +0 -0
  64. {letify-1.2.4 → letify-2.0.0}/examples/nvfp4-lora/.letify.example +0 -0
  65. {letify-1.2.4 → letify-2.0.0}/examples/nvfp4-lora/00_smoke.py +0 -0
  66. {letify-1.2.4 → letify-2.0.0}/examples/nvfp4-lora/01_sweep.py +0 -0
  67. {letify-1.2.4 → letify-2.0.0}/examples/nvfp4-lora/02_resume.py +0 -0
  68. {letify-1.2.4 → letify-2.0.0}/examples/nvfp4-lora/03_watch.py +0 -0
  69. {letify-1.2.4 → letify-2.0.0}/examples/nvfp4-lora/README.md +0 -0
  70. {letify-1.2.4 → letify-2.0.0}/examples/nvfp4-lora/README_ko.md +0 -0
  71. {letify-1.2.4 → letify-2.0.0}/examples/nvfp4-lora/common.py +0 -0
  72. {letify-1.2.4 → letify-2.0.0}/examples/nvfp4-lora/recipe.py +0 -0
  73. {letify-1.2.4 → letify-2.0.0}/letify/cli.py +0 -0
  74. {letify-1.2.4 → letify-2.0.0}/letify/config/__init__.py +0 -0
  75. {letify-1.2.4 → letify-2.0.0}/letify/config/inventory.py +0 -0
  76. {letify-1.2.4 → letify-2.0.0}/letify/config/login.py +0 -0
  77. {letify-1.2.4 → letify-2.0.0}/letify/config/schema.py +0 -0
  78. {letify-1.2.4 → letify-2.0.0}/letify/config/secrets.py +0 -0
  79. {letify-1.2.4 → letify-2.0.0}/letify/config/writer.py +0 -0
  80. {letify-1.2.4 → letify-2.0.0}/letify/declare/__init__.py +0 -0
  81. {letify-1.2.4 → letify-2.0.0}/letify/declare/cache.py +0 -0
  82. {letify-1.2.4 → letify-2.0.0}/letify/declare/env.py +0 -0
  83. {letify-1.2.4 → letify-2.0.0}/letify/declare/function.py +0 -0
  84. {letify-1.2.4 → letify-2.0.0}/letify/declare/instance.py +0 -0
  85. {letify-1.2.4 → letify-2.0.0}/letify/errors.py +0 -0
  86. {letify-1.2.4 → letify-2.0.0}/letify/launcher.py +0 -0
  87. {letify-1.2.4 → letify-2.0.0}/letify/paths.py +0 -0
  88. {letify-1.2.4 → letify-2.0.0}/letify/protocol/__init__.py +0 -0
  89. {letify-1.2.4 → letify-2.0.0}/letify/protocol/codec.py +0 -0
  90. {letify-1.2.4 → letify-2.0.0}/letify/protocol/driver.py +0 -0
  91. {letify-1.2.4 → letify-2.0.0}/letify/protocol/handle.py +0 -0
  92. {letify-1.2.4 → letify-2.0.0}/letify/protocol/wire.py +0 -0
  93. {letify-1.2.4 → letify-2.0.0}/letify/providers/__init__.py +0 -0
  94. {letify-1.2.4 → letify-2.0.0}/letify/providers/colab_files.py +0 -0
  95. {letify-1.2.4 → letify-2.0.0}/letify/providers/colab_keepalive.py +0 -0
  96. {letify-1.2.4 → letify-2.0.0}/letify/providers/elice.py +0 -0
  97. {letify-1.2.4 → letify-2.0.0}/letify/providers/kaggle_adapter.py +0 -0
  98. {letify-1.2.4 → letify-2.0.0}/letify/providers/modal.py +0 -0
  99. {letify-1.2.4 → letify-2.0.0}/letify/providers/modal_adapter.py +0 -0
  100. {letify-1.2.4 → letify-2.0.0}/letify/providers/naming.py +0 -0
  101. {letify-1.2.4 → letify-2.0.0}/letify/providers/tunnel.py +0 -0
  102. {letify-1.2.4 → letify-2.0.0}/letify/providers/usage.py +0 -0
  103. {letify-1.2.4 → letify-2.0.0}/letify/remoting/__init__.py +0 -0
  104. {letify-1.2.4 → letify-2.0.0}/letify/remoting/capability.py +0 -0
  105. {letify-1.2.4 → letify-2.0.0}/letify/remoting/device/__init__.py +0 -0
  106. {letify-1.2.4 → letify-2.0.0}/letify/remoting/device/client.py +0 -0
  107. {letify-1.2.4 → letify-2.0.0}/letify/remoting/device/cuda.py +0 -0
  108. {letify-1.2.4 → letify-2.0.0}/letify/remoting/device/executor.py +0 -0
  109. {letify-1.2.4 → letify-2.0.0}/letify/remoting/device/frames.py +0 -0
  110. {letify-1.2.4 → letify-2.0.0}/letify/remoting/device/guard.py +0 -0
  111. {letify-1.2.4 → letify-2.0.0}/letify/remoting/device/tensor.py +0 -0
  112. {letify-1.2.4 → letify-2.0.0}/letify/remoting/device/trace.py +0 -0
  113. {letify-1.2.4 → letify-2.0.0}/letify/remoting/device/value.py +0 -0
  114. {letify-1.2.4 → letify-2.0.0}/letify/remoting/loader.py +0 -0
  115. {letify-1.2.4 → letify-2.0.0}/letify/remoting/probe.py +0 -0
  116. {letify-1.2.4 → letify-2.0.0}/letify/render.py +0 -0
  117. {letify-1.2.4 → letify-2.0.0}/letify/runtime/__init__.py +0 -0
  118. {letify-1.2.4 → letify-2.0.0}/letify/runtime/bootstrap.py +0 -0
  119. {letify-1.2.4 → letify-2.0.0}/letify/runtime/channel.py +0 -0
  120. {letify-1.2.4 → letify-2.0.0}/letify/runtime/lease.py +0 -0
  121. {letify-1.2.4 → letify-2.0.0}/letify/runtime/pool.py +0 -0
  122. {letify-1.2.4 → letify-2.0.0}/letify/runtime/session.py +0 -0
  123. {letify-1.2.4 → letify-2.0.0}/letify/runtime/telemetry.py +0 -0
  124. {letify-1.2.4 → letify-2.0.0}/letify/store/__init__.py +0 -0
  125. {letify-1.2.4 → letify-2.0.0}/letify/store/backends/__init__.py +0 -0
  126. {letify-1.2.4 → letify-2.0.0}/letify/store/backends/filesystem.py +0 -0
  127. {letify-1.2.4 → letify-2.0.0}/letify/store/backends/google_auth.py +0 -0
  128. {letify-1.2.4 → letify-2.0.0}/letify/store/backends/layout.py +0 -0
  129. {letify-1.2.4 → letify-2.0.0}/letify/store/backends/objects.py +0 -0
  130. {letify-1.2.4 → letify-2.0.0}/letify/store/cas.py +0 -0
  131. {letify-1.2.4 → letify-2.0.0}/letify/store/sendorder.py +0 -0
  132. {letify-1.2.4 → letify-2.0.0}/letify/store/volume.py +0 -0
  133. {letify-1.2.4 → letify-2.0.0}/letify/tools.py +0 -0
  134. {letify-1.2.4 → letify-2.0.0}/letify/transport/__init__.py +0 -0
  135. {letify-1.2.4 → letify-2.0.0}/letify/transport/agent.py +0 -0
  136. {letify-1.2.4 → letify-2.0.0}/letify/transport/announce.py +0 -0
  137. {letify-1.2.4 → letify-2.0.0}/letify/transport/link.py +0 -0
  138. {letify-1.2.4 → letify-2.0.0}/letify/transport/probe.py +0 -0
  139. {letify-1.2.4 → letify-2.0.0}/letify/transport/setup.py +0 -0
  140. {letify-1.2.4 → letify-2.0.0}/letify/transport/sshopts.py +0 -0
  141. {letify-1.2.4 → letify-2.0.0}/letify-core/.gitignore +0 -0
  142. {letify-1.2.4 → letify-2.0.0}/letify-core/letify-agent/Cargo.toml +0 -0
  143. {letify-1.2.4 → letify-2.0.0}/letify-core/letify-agent/src/driver.rs +0 -0
  144. {letify-1.2.4 → letify-2.0.0}/letify-core/letify-agent/src/main.rs +0 -0
  145. {letify-1.2.4 → letify-2.0.0}/letify-core/letify-driver/Cargo.toml +0 -0
  146. {letify-1.2.4 → letify-2.0.0}/letify-core/letify-driver/src/client.rs +0 -0
  147. {letify-1.2.4 → letify-2.0.0}/letify-core/letify-driver/src/exports.rs +0 -0
  148. {letify-1.2.4 → letify-2.0.0}/letify-core/letify-driver/src/image.rs +0 -0
  149. {letify-1.2.4 → letify-2.0.0}/letify-core/letify-driver/src/lib.rs +0 -0
  150. {letify-1.2.4 → letify-2.0.0}/letify-core/letify-driver/src/status.rs +0 -0
  151. {letify-1.2.4 → letify-2.0.0}/letify-core/letify-driver/src/table.rs +0 -0
  152. {letify-1.2.4 → letify-2.0.0}/letify-core/letify-wire/Cargo.toml +0 -0
  153. {letify-1.2.4 → letify-2.0.0}/letify-core/letify-wire/src/lib.rs +0 -0
  154. {letify-1.2.4 → letify-2.0.0}/letify-core/letify-wire/tests/throughput.rs +0 -0
  155. {letify-1.2.4 → letify-2.0.0}/letify-core/rust-toolchain.toml +0 -0
  156. {letify-1.2.4 → letify-2.0.0}/letify-ext/.vscodeignore +0 -0
  157. {letify-1.2.4 → letify-2.0.0}/letify-ext/LICENSE +0 -0
  158. {letify-1.2.4 → letify-2.0.0}/letify-ext/README.md +0 -0
  159. {letify-1.2.4 → letify-2.0.0}/letify-ext/media/icon.svg +0 -0
  160. {letify-1.2.4 → letify-2.0.0}/letify-ext/src/cli.ts +0 -0
  161. {letify-1.2.4 → letify-2.0.0}/letify-ext/src/extension.ts +0 -0
  162. {letify-1.2.4 → letify-2.0.0}/letify-ext/src/history.ts +0 -0
  163. {letify-1.2.4 → letify-2.0.0}/letify-ext/src/model.ts +0 -0
  164. {letify-1.2.4 → letify-2.0.0}/letify-ext/src/poll.ts +0 -0
  165. {letify-1.2.4 → letify-2.0.0}/letify-ext/src/render.ts +0 -0
  166. {letify-1.2.4 → letify-2.0.0}/letify-ext/test/cli.test.ts +0 -0
  167. {letify-1.2.4 → letify-2.0.0}/letify-ext/test/manifest.test.ts +0 -0
  168. {letify-1.2.4 → letify-2.0.0}/letify-ext/test/model.test.ts +0 -0
  169. {letify-1.2.4 → letify-2.0.0}/letify-ext/test/regressions.test.ts +0 -0
  170. {letify-1.2.4 → letify-2.0.0}/letify-ext/tsconfig.json +0 -0
  171. {letify-1.2.4 → letify-2.0.0}/letify_providers.py +0 -0
  172. {letify-1.2.4 → letify-2.0.0}/scripts/sync-main.sh +0 -0
  173. {letify-1.2.4 → letify-2.0.0}/scripts/version.py +0 -0
  174. {letify-1.2.4 → letify-2.0.0}/tests/conftest.py +0 -0
  175. {letify-1.2.4 → letify-2.0.0}/tests/fake_kaggle_adapter.py +0 -0
  176. {letify-1.2.4 → letify-2.0.0}/tests/fake_modal_adapter.py +0 -0
  177. {letify-1.2.4 → letify-2.0.0}/tests/test_cli.py +0 -0
  178. {letify-1.2.4 → letify-2.0.0}/tests/test_cli_output.py +0 -0
  179. {letify-1.2.4 → letify-2.0.0}/tests/test_colab_keepalive.py +0 -0
  180. {letify-1.2.4 → letify-2.0.0}/tests/test_config.py +0 -0
  181. {letify-1.2.4 → letify-2.0.0}/tests/test_core.py +0 -0
  182. {letify-1.2.4 → letify-2.0.0}/tests/test_data.py +0 -0
  183. {letify-1.2.4 → letify-2.0.0}/tests/test_data_streaming.py +0 -0
  184. {letify-1.2.4 → letify-2.0.0}/tests/test_declare.py +0 -0
  185. {letify-1.2.4 → letify-2.0.0}/tests/test_device.py +0 -0
  186. {letify-1.2.4 → letify-2.0.0}/tests/test_editor_sessions.py +0 -0
  187. {letify-1.2.4 → letify-2.0.0}/tests/test_elice.py +0 -0
  188. {letify-1.2.4 → letify-2.0.0}/tests/test_frames.py +0 -0
  189. {letify-1.2.4 → letify-2.0.0}/tests/test_install.py +0 -0
  190. {letify-1.2.4 → letify-2.0.0}/tests/test_kaggle.py +0 -0
  191. {letify-1.2.4 → letify-2.0.0}/tests/test_kaggle_cookie.py +0 -0
  192. {letify-1.2.4 → letify-2.0.0}/tests/test_notebook.py +0 -0
  193. {letify-1.2.4 → letify-2.0.0}/tests/test_paths.py +0 -0
  194. {letify-1.2.4 → letify-2.0.0}/tests/test_protocol.py +0 -0
  195. {letify-1.2.4 → letify-2.0.0}/tests/test_remoting.py +0 -0
  196. {letify-1.2.4 → letify-2.0.0}/tests/test_render.py +0 -0
  197. {letify-1.2.4 → letify-2.0.0}/tests/test_runtime.py +0 -0
  198. {letify-1.2.4 → letify-2.0.0}/tests/test_ship.py +0 -0
  199. {letify-1.2.4 → letify-2.0.0}/tests/test_store.py +0 -0
  200. {letify-1.2.4 → letify-2.0.0}/tests/test_tunnel_setup.py +0 -0
  201. {letify-1.2.4 → letify-2.0.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 1.2.4
3
+ Version: 2.0.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__ = "1.2.4"
65
+ __version__ = "2.0.0"
66
66
 
67
67
 
68
68
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -626,6 +626,64 @@ def remote_tailcat_source() -> str:
626
626
  )
627
627
 
628
628
 
629
+ def remote_quic_source(version: str) -> str:
630
+ """Prepare ``letify-quic`` on the remote from the letify wheel of this version.
631
+
632
+ Spec "QUIC over a punched UDP pair": the binary changes with every letify release, so
633
+ a hash pinned in this source cannot describe the build that is about to be made. The
634
+ remote therefore fetches the wheel for its own platform at exactly this version, which
635
+ PyPI verifies, and takes the binary out of it.
636
+ """
637
+ import inspect
638
+
639
+ return (
640
+ inspect.getsource(_remote_quic)
641
+ + f"\nbinary = _remote_quic({version!r})\n"
642
+ + "import json\nprint('LETIFY-QUIC ' + json.dumps(binary))\n"
643
+ )
644
+
645
+
646
+ def _remote_quic(version):
647
+ """Take letify-quic out of the letify wheel for this machine, standard library only."""
648
+ import json
649
+ import os
650
+ import subprocess
651
+ import sys
652
+ import tempfile
653
+ import zipfile
654
+ from pathlib import Path
655
+
656
+ inside = "letify/remoting/lib/letify-quic"
657
+ target = Path(tempfile.gettempdir()) / f"letify-quic-{version}"
658
+ if target.is_file() and os.access(target, os.X_OK):
659
+ return str(target)
660
+ directory = tempfile.mkdtemp(prefix="letify-quic-")
661
+ done = subprocess.run(
662
+ [
663
+ sys.executable, "-m", "pip", "download", f"letify=={version}",
664
+ "--no-deps", "--only-binary", ":all:", "-d", directory,
665
+ ],
666
+ capture_output=True,
667
+ text=True,
668
+ timeout=600,
669
+ )
670
+ if done.returncode != 0:
671
+ raise RuntimeError(
672
+ "could not download the letify wheel for letify-quic: "
673
+ + (done.stderr or done.stdout).strip()[-400:]
674
+ )
675
+ wheels = sorted(Path(directory).glob("letify-*.whl"))
676
+ if not wheels:
677
+ raise RuntimeError("pip downloaded no letify wheel")
678
+ with zipfile.ZipFile(wheels[0]) as archive:
679
+ if inside not in archive.namelist():
680
+ raise RuntimeError(f"{wheels[0].name} carries no {inside}")
681
+ target.write_bytes(archive.read(inside))
682
+ target.chmod(0o755)
683
+ json.dumps(str(target))
684
+ return str(target)
685
+
686
+
629
687
  def _remote_tailcat(version, release, pins):
630
688
  """Install a verified Linux tailcat using only the runtime's standard library."""
631
689
  import hashlib
@@ -694,6 +694,79 @@ def _op_data_put(request):
694
694
  return {"ok": True, "value": None}
695
695
 
696
696
 
697
+ def _parallel_partial(root, digest):
698
+ """Where parallel senders write. One name per digest, not per process or thread."""
699
+ return "%s%s.parallel" % (_data_file(root, digest), _DATA_PARTIAL)
700
+
701
+
702
+ def _op_data_begin(request):
703
+ """Make the file parallel senders will fill, of the size it will end up.
704
+
705
+ Spec "Several connections at once". A placement starts here rather than at the write
706
+ of offset zero, because with several connections offset zero is just another range and
707
+ may arrive last: truncating on it would throw away what the others had written.
708
+ """
709
+ partial = _parallel_partial(request["dir"], request["digest"])
710
+ os.makedirs(os.path.dirname(partial), exist_ok=True)
711
+ with open(partial, "wb") as handle:
712
+ handle.truncate(int(request["size"]))
713
+ return {"ok": True, "value": None}
714
+
715
+
716
+ def _op_data_put_at(request):
717
+ """Write one piece of a file blob at its offset, for several senders at once.
718
+
719
+ No hashing: a hash over a stream cannot be computed out of order, so ``data_seal``
720
+ does it by reading the finished file.
721
+ """
722
+ partial = _parallel_partial(request["dir"], request["digest"])
723
+ chunk = request.pop("chunk")
724
+ handle = open(partial, "r+b")
725
+ try:
726
+ handle.seek(int(request["offset"]))
727
+ handle.write(chunk)
728
+ finally:
729
+ handle.close()
730
+ chunk = None
731
+ return {"ok": True, "value": None}
732
+
733
+
734
+ def _op_data_seal(request):
735
+ """Hash a file assembled by parallel senders, then commit it as ``data_put`` does."""
736
+ digest = request["digest"]
737
+ root = request["dir"]
738
+ partial = _parallel_partial(root, digest)
739
+ final = _data_file(root, digest)
740
+ size = request.get("size")
741
+ try:
742
+ found = os.path.getsize(partial)
743
+ except OSError as exc:
744
+ raise ValueError("no pieces of file blob %s arrived: %s" % (digest, exc))
745
+ if size is not None and found != int(size):
746
+ raise ValueError(
747
+ "file blob %s is short: %d of %d bytes arrived" % (digest, found, int(size))
748
+ )
749
+ hasher = _data_hasher()
750
+ if hasher is not None:
751
+ with open(partial, "rb") as handle:
752
+ while True:
753
+ piece = handle.read(1 << 22)
754
+ if not piece:
755
+ break
756
+ hasher.update(piece)
757
+ got = hasher.hexdigest(length=16)
758
+ if got != digest:
759
+ os.remove(partial)
760
+ raise ValueError("file blob %s arrived with digest %s" % (digest, got))
761
+ try:
762
+ os.chmod(partial, 0o444)
763
+ except OSError:
764
+ pass
765
+ os.replace(partial, final)
766
+ _data_arrived(root, digest)
767
+ return {"ok": True, "value": None}
768
+
769
+
697
770
  def _op_data_pull(request):
698
771
  """Download file blobs from the bucket, eight at a time, then forget the headers."""
699
772
  import urllib.request
@@ -1875,6 +1948,9 @@ _OPS = {
1875
1948
  "have": _op_have,
1876
1949
  "data_have": _op_data_have,
1877
1950
  "data_put": _op_data_put,
1951
+ "data_begin": _op_data_begin,
1952
+ "data_put_at": _op_data_put_at,
1953
+ "data_seal": _op_data_seal,
1878
1954
  "data_pull": _op_data_pull,
1879
1955
  "data_evict": _op_data_evict,
1880
1956
  "data_cache": _op_data_cache,
@@ -260,6 +260,42 @@ class Provider(abc.ABC):
260
260
  return "ephemeral"
261
261
  return self.default_persistence
262
262
 
263
+ @property
264
+ def transfer_streams(self) -> int:
265
+ """Connections a large blob is split across, from ``transfer_streams``.
266
+
267
+ Spec "Several connections at once": one TCP connection over a long round trip
268
+ carries a fraction of what the path will carry, and independent connections add
269
+ up. One keeps the single stream.
270
+ """
271
+ from ..store.pathdata import STREAMS
272
+
273
+ value = self.config.option("transfer_streams")
274
+ return max(1, int(value)) if isinstance(value, (int, float)) else STREAMS
275
+
276
+ @property
277
+ def transfer_parallel_rtt_ms(self) -> int:
278
+ """The round trip above which a blob is split, from ``transfer_parallel_rtt_ms``.
279
+
280
+ Below it one stream already carries everything the link will carry, so the extra
281
+ connections are pure cost. Spec "Several connections at once".
282
+ """
283
+ from ..store.pathdata import PARALLEL_RTT_MS
284
+
285
+ value = self.config.option("transfer_parallel_rtt_ms")
286
+ return max(0, int(value)) if isinstance(value, (int, float)) else PARALLEL_RTT_MS
287
+
288
+ @property
289
+ def transfer_parallel_mib(self) -> int:
290
+ """The size above which a blob is split, from ``transfer_parallel_mib``.
291
+
292
+ Below it, opening more connections costs more than it saves.
293
+ """
294
+ from ..store.pathdata import PARALLEL_MIB
295
+
296
+ value = self.config.option("transfer_parallel_mib")
297
+ return max(0, int(value)) if isinstance(value, (int, float)) else PARALLEL_MIB
298
+
263
299
  @property
264
300
  def persistent(self) -> bool:
265
301
  return self.persistence == "persistent"
@@ -323,30 +323,17 @@ class Colab(Shell):
323
323
  def rendezvous(self, runtime: Runtime | None = None) -> Rendezvous | None:
324
324
  if runtime is None:
325
325
  return None
326
- from ..transport.rendezvous import ColabRendezvous
326
+ from ..transport.rendezvous import ColabRendezvous, prepare_tailcat
327
327
 
328
328
  name = runtime.name
329
329
 
330
- def prepare_tailcat() -> str | None:
331
- from .. import install
332
-
333
- if not (
334
- install.find("tailcat", link_cache=False) or self.config.option("tailcat_binary")
335
- ):
336
- return None
337
- output = self._exec(name, install.remote_tailcat_source(), 180)
338
- for line in output.splitlines():
339
- if line.startswith("LETIFY-TAILCAT "):
340
- return json.loads(line[len("LETIFY-TAILCAT ") :])
341
- raise RuntimeFailure(
342
- "tailcat installation gave no verified binary path",
343
- stderr=output.strip(),
344
- )
330
+ def run(source: str, timeout: float) -> str:
331
+ return self._exec(name, source, timeout)
345
332
 
346
333
  return ColabRendezvous(
347
- lambda source, timeout: self._exec(name, source, timeout),
334
+ run,
348
335
  self._public_key(),
349
- prepare_tailcat=prepare_tailcat,
336
+ prepare_tailcat=prepare_tailcat(run, self.config.option("tailcat_binary")),
350
337
  )
351
338
 
352
339
  def fallback(self, runtime: Runtime | None = None) -> Callable[[], Link] | None:
@@ -25,6 +25,7 @@ import binascii
25
25
  import json
26
26
  import math
27
27
  import re
28
+ import textwrap
28
29
  import time
29
30
  import urllib.error
30
31
  import urllib.parse
@@ -929,6 +930,48 @@ class KaggleChannel(FramedChannel):
929
930
  process.kill()
930
931
 
931
932
 
933
+ def _capture_stdout(source: str) -> str:
934
+ """``source`` with its standard output captured into ``__letify_value__``.
935
+
936
+ A ``from __future__`` import has to be the first statement of the module, so those
937
+ lines are hoisted above the wrapper rather than indented under it. Without that the
938
+ rendezvous program, which begins with one, fails to compile and the punch reports that
939
+ the remote half gave no answer.
940
+ """
941
+ lines = source.splitlines(keepends=True)
942
+ future = [line for line in lines if line.lstrip().startswith("from __future__")]
943
+ rest = [line for line in lines if not line.lstrip().startswith("from __future__")]
944
+ return (
945
+ "".join(future)
946
+ + "import contextlib as _c, io as _io\n"
947
+ + "__letify_buffer__ = _io.StringIO()\n"
948
+ + "with _c.redirect_stdout(__letify_buffer__):\n"
949
+ + textwrap.indent("".join(rest), " ")
950
+ + "\n__letify_value__ = __letify_buffer__.getvalue()\n"
951
+ )
952
+
953
+
954
+ def _key_path():
955
+ """Where letify keeps the key pair a Kaggle rendezvous authorizes."""
956
+ from pathlib import Path as _Path
957
+
958
+ return _Path.home() / ".ssh" / "id_letify"
959
+
960
+
961
+ def _public_key() -> str | None:
962
+ """letify's own public key, so the rendezvous can authorize it on the machine."""
963
+ try:
964
+ return _key_path().with_suffix(".pub").read_text(encoding="utf-8").strip() or None
965
+ except OSError:
966
+ return None
967
+
968
+
969
+ def _private_key() -> str | None:
970
+ """The private half, which the SSH command over a punched link logs in with."""
971
+ path = _key_path()
972
+ return str(path) if path.exists() else None
973
+
974
+
932
975
  class Kaggle(Provider):
933
976
  """One Kaggle account."""
934
977
 
@@ -1005,6 +1048,8 @@ class Kaggle(Provider):
1005
1048
  #: runs its programs in. The notebook is this runtime's own, created in
1006
1049
  #: ``open_channel`` and deleted in ``stop``.
1007
1050
  self._kernels: dict[str, tuple[Session, str, KaggleChannel, int, int, str | None]] = {}
1051
+ #: The link chosen over the bridge, per runtime, closed with the session.
1052
+ self._links: dict[str, Any] = {}
1008
1053
  #: Monotonic time of the last confirmed-live cookie check, or None before the first.
1009
1054
  self._cookie_checked_at: float | None = None
1010
1055
 
@@ -1039,6 +1084,126 @@ class Kaggle(Provider):
1039
1084
  self._cookie_checked_at = now
1040
1085
  return cookie
1041
1086
 
1087
+ # -- an SSH link over the kernel ----------------------------------------------
1088
+
1089
+ def rendezvous_over(self, channel: Any) -> Any:
1090
+ """The bridge as a rendezvous: it runs one program on the machine and reports.
1091
+
1092
+ Spec "Kaggle runtimes", An SSH link over the kernel. The kernel is how letify
1093
+ reaches the machine; it does not have to be how the session is carried.
1094
+ """
1095
+ from ..transport.rendezvous import CommandRendezvous, prepare_tailcat
1096
+
1097
+ bridge = channel
1098
+
1099
+ class KaggleRendezvous(CommandRendezvous):
1100
+ """``eval`` on the open bridge, which is a standard library program on the VM."""
1101
+
1102
+ def extras(self) -> dict[str, Any]:
1103
+ # The VM has no key of letify's yet, and no SSH server running.
1104
+ extra: dict[str, Any] = {"start_sshd": True}
1105
+ public = _public_key()
1106
+ if public:
1107
+ extra["authorized_key"] = public
1108
+ return extra
1109
+
1110
+ def run_python(self, source: str, timeout: float | None) -> str:
1111
+ # The rendezvous program prints its answer, and ``eval`` returns what the
1112
+ # source left in __letify_value__, not what it printed: the print goes out
1113
+ # as a STDOUT frame and never reaches the caller. So the source runs with
1114
+ # its standard output captured into that name.
1115
+ captured = _capture_stdout(source)
1116
+ value, _ = bridge.request(
1117
+ {"op": "eval", "source": captured}, timeout=timeout
1118
+ )
1119
+ return value if isinstance(value, str) else str(value or "")
1120
+
1121
+ rendezvous = KaggleRendezvous()
1122
+ # A Kaggle image ships no tailcat, so the request has to carry an installed path.
1123
+ rendezvous._prepare_tailcat = prepare_tailcat(
1124
+ rendezvous.run_python, self.config.option("tailcat_binary")
1125
+ )
1126
+ return rendezvous
1127
+
1128
+ def target_over(self, channel: Any) -> Any:
1129
+ """What the strategies need, with no address because there is none to dial."""
1130
+ from ..transport import nat
1131
+ from ..transport.strategies import Target
1132
+
1133
+ user = self.config.option("user")
1134
+ return Target(
1135
+ alias=self.alias,
1136
+ rendezvous=self.rendezvous_over(channel),
1137
+ remote_python=self.remote_python,
1138
+ stun=nat.DEFAULT_STUN,
1139
+ workspace=self.workspace_root,
1140
+ # The rendezvous authorized this key for root on the VM, so the link has to
1141
+ # offer it and name that user. Without them the VM closes the banner exchange.
1142
+ key=_private_key(),
1143
+ user=user if isinstance(user, str) and user else "root",
1144
+ )
1145
+
1146
+ def strategies(self) -> list[Any]:
1147
+ """The strategies a session with no address can use, in rank order."""
1148
+ from ..transport.strategies import ProviderFallback, QuicUDP, TailcatUDP, TCPPunch
1149
+
1150
+ return [TCPPunch(), TailcatUDP(), QuicUDP(), ProviderFallback(rank=6)]
1151
+
1152
+ def link_over(self, channel: Any) -> Any:
1153
+ """Race the strategies over the bridge and return the link that wins."""
1154
+ from ..transport.announce import printer
1155
+ from ..transport.pipeline import LinkCache, Pipeline, network_fingerprint
1156
+
1157
+ target = self.target_over(channel)
1158
+ return Pipeline(
1159
+ self.strategies(),
1160
+ target=target,
1161
+ alias=self.alias,
1162
+ cache=LinkCache(self.alias),
1163
+ fingerprint=lambda: network_fingerprint(target.stun),
1164
+ say=printer(self.announce),
1165
+ floor=self.link_floor,
1166
+ ).connect()
1167
+
1168
+ @property
1169
+ def link_floor(self) -> Any:
1170
+ """The slowest probe this account accepts, as a shell account's does."""
1171
+ from ..transport.pipeline import MIB, LinkFloor
1172
+
1173
+ default = LinkFloor.default()
1174
+ min_mib = self.config.option("min_mib_per_s")
1175
+ max_rtt = self.config.option("max_rtt_ms")
1176
+ return LinkFloor(
1177
+ min_bps=float(min_mib) * MIB if isinstance(min_mib, (int, float)) else default.min_bps,
1178
+ max_rtt_ms=float(max_rtt) if isinstance(max_rtt, (int, float)) else default.max_rtt_ms,
1179
+ )
1180
+
1181
+ @property
1182
+ def remote_python(self) -> str:
1183
+ value = self.config.option("remote_python")
1184
+ return str(value) if value else "python3"
1185
+
1186
+ def channel_over(self, bridge: Any, *, name: str) -> Channel:
1187
+ """A worker over the chosen link, or the bridge when nothing was chosen.
1188
+
1189
+ Spec "Kaggle runtimes", An SSH link over the kernel: a network that cannot punch
1190
+ keeps the bridge, which is what every Kaggle session used until now.
1191
+ """
1192
+ import shlex as _shlex
1193
+
1194
+ from ..protocol.worker import BOOTSTRAP
1195
+ from ..runtime.channel import PersistentChannel
1196
+
1197
+ try:
1198
+ link = self.link_over(bridge)
1199
+ except Exception:
1200
+ return bridge
1201
+ self._links[name] = link
1202
+ return PersistentChannel(
1203
+ link.ssh_command(f"{self.remote_python} -u -c {_shlex.quote(BOOTSTRAP)}"),
1204
+ name=name,
1205
+ )
1206
+
1042
1207
  def open_channel(self, runtime: Runtime) -> Channel:
1043
1208
  """Start a session from the cookie and open one worker in one cell of it.
1044
1209
 
@@ -1065,7 +1230,9 @@ class Kaggle(Provider):
1065
1230
  session=session,
1066
1231
  )
1067
1232
  self._kernels[runtime.name] = (session, kernel, channel, run, notebook, slug)
1068
- return channel
1233
+ # Spec "Kaggle runtimes", An SSH link over the kernel: the bridge is the
1234
+ # rendezvous, and the session rides a link when one is chosen over it.
1235
+ return self.channel_over(channel, name=runtime.name)
1069
1236
 
1070
1237
  def stop(self, runtime: Runtime) -> None:
1071
1238
  """Close the bridge, delete the kernel, cancel the session run, delete the notebook.
@@ -1081,6 +1248,12 @@ class Kaggle(Provider):
1081
1248
  if held is None:
1082
1249
  return
1083
1250
  session, kernel, channel, run, notebook, slug = held
1251
+ link = self._links.pop(runtime.name, None)
1252
+ if link is not None:
1253
+ try:
1254
+ link.close()
1255
+ except Exception:
1256
+ pass
1084
1257
  try:
1085
1258
  channel.close()
1086
1259
  except OSError:
@@ -143,6 +143,20 @@ class Local(Provider):
143
143
 
144
144
  return PersistentChannel([str(interpreter), "-u", "-c", BOOTSTRAP], name=runtime.name)
145
145
 
146
+ def transfer_channel(self, runtime: Runtime | None) -> Channel:
147
+ """Another worker subprocess for one transfer stream.
148
+
149
+ Spec "Several connections at once" is about kernel connections, and here there is
150
+ no network to make one over. A subprocess is the local equivalent, and having it
151
+ lets the parallel placement be exercised over the real framed path.
152
+ """
153
+ from ..protocol.worker import BOOTSTRAP
154
+ from ..runtime.channel import PersistentChannel
155
+
156
+ return PersistentChannel(
157
+ [str(sys.executable), "-u", "-c", BOOTSTRAP], name=f"{self.alias}-transfer"
158
+ )
159
+
146
160
  def device_channel(self, runtime: Runtime) -> Channel:
147
161
  """The session's call worker subprocess, which hosts the PyTorch device executor."""
148
162
  assert runtime.channel is not None
@@ -140,6 +140,28 @@ class Shell(Provider):
140
140
  value = self.config.option("reverse_ssh")
141
141
  return dict(value) if isinstance(value, Mapping) else None
142
142
 
143
+ @property
144
+ def connect_back(self) -> dict[str, Any] | None:
145
+ """Where the remote can reach this machine, from ``connect_back``.
146
+
147
+ Spec "Connect back". ``port`` is required; ``address`` is optional and the STUN
148
+ answer stands in for it when the port is forwarded here but the name is not known.
149
+ """
150
+ value = self.config.option("connect_back")
151
+ if not isinstance(value, Mapping) or not value.get("port"):
152
+ return None
153
+ return dict(value)
154
+
155
+ @property
156
+ def port_mapping(self) -> bool:
157
+ """Whether to ask the NAT for a forwarding, from ``port_mapping``.
158
+
159
+ Spec "Asking the NAT for a mapping": on by default, because a device that does not
160
+ speak one of the three protocols answers nothing and the attempt costs a second.
161
+ """
162
+ value = self.config.option("port_mapping")
163
+ return True if value is None else bool(value)
164
+
143
165
  @property
144
166
  def link_floor(self) -> LinkFloor:
145
167
  """The slowest probe this account accepts, from ``min_mib_per_s`` and ``max_rtt_ms``.
@@ -231,17 +253,22 @@ class Shell(Provider):
231
253
  def strategies(self) -> list[Strategy]:
232
254
  """The ranked strategy list. Reverse SSH joins only when the account sets it."""
233
255
  from ..transport.strategies import (
256
+ ConnectBack,
234
257
  DirectSSH,
235
258
  ProviderFallback,
259
+ QuicUDP,
236
260
  ReverseSSH,
237
261
  TailcatUDP,
238
262
  TCPPunch,
239
263
  )
240
264
 
241
- chosen: list[Strategy] = [DirectSSH(), TCPPunch(), TailcatUDP()]
265
+ chosen: list[Strategy] = [DirectSSH()]
266
+ if self.connect_back or self.port_mapping:
267
+ chosen.append(ConnectBack(mapping=self.port_mapping))
268
+ chosen.extend((TCPPunch(), TailcatUDP(), QuicUDP()))
242
269
  if self.reverse_ssh:
243
270
  chosen.append(ReverseSSH())
244
- chosen.append(ProviderFallback(rank=5 if self.reverse_ssh else 4))
271
+ chosen.append(ProviderFallback(rank=7 if self.reverse_ssh else 6))
245
272
  return chosen
246
273
 
247
274
  def fallback(self, runtime: Runtime | None = None) -> Callable[[], Link] | None:
@@ -269,6 +296,7 @@ class Shell(Provider):
269
296
  remote_python=self.remote_python,
270
297
  rendezvous=self.rendezvous(runtime),
271
298
  reverse_ssh=self.reverse_ssh,
299
+ connect_back=self.connect_back,
272
300
  fallback=self.fallback(runtime),
273
301
  stun=stun,
274
302
  tailcat=self.tailcat_binary,
@@ -463,6 +491,42 @@ class Shell(Provider):
463
491
  name=runtime.name,
464
492
  )
465
493
 
494
+ def transfer_command(self, runtime: Runtime | None) -> list[str]:
495
+ """An SSH command for one transfer connection, outside the multiplexed one.
496
+
497
+ Spec "Several connections at once": every SSH command letify builds carries
498
+ ``ControlMaster=auto``, which puts further sessions inside the first connection.
499
+ A transfer stream has to be its own kernel connection, so it turns that off. A
500
+ punched link redials for each, which is already one connection per session.
501
+ """
502
+ import shlex as _shlex
503
+
504
+ from ..protocol.worker import BOOTSTRAP
505
+
506
+ link = self.link(runtime)
507
+ command = link.ssh_command(
508
+ self.remote_command(f"{self.remote_python} -u -c {_shlex.quote(BOOTSTRAP)}")
509
+ )
510
+ kept: list[str] = []
511
+ index = 0
512
+ while index < len(command):
513
+ part = command[index]
514
+ if part == "-o" and index + 1 < len(command):
515
+ option = command[index + 1]
516
+ if option.startswith(("ControlMaster=", "ControlPath=", "ControlPersist=")):
517
+ index += 2
518
+ continue
519
+ kept.append(part)
520
+ index += 1
521
+ # After the program name, so they are options rather than the remote command.
522
+ return [kept[0], "-o", "ControlMaster=no", "-o", "ControlPath=none", *kept[1:]]
523
+
524
+ def transfer_channel(self, runtime: Runtime | None) -> Channel:
525
+ """One channel for one transfer stream, on a connection of its own."""
526
+ from ..runtime.channel import PersistentChannel
527
+
528
+ return PersistentChannel(self.transfer_command(runtime), name=f"{self.alias}-transfer")
529
+
466
530
  def device_channel(self, runtime: Runtime) -> Channel:
467
531
  """The session's call channel, which hosts the PyTorch device executor."""
468
532
  from ..errors import UnsupportedMode