letify 2.4.4__tar.gz → 2.4.5__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 (202) hide show
  1. {letify-2.4.4 → letify-2.4.5}/PKG-INFO +1 -1
  2. {letify-2.4.4 → letify-2.4.5}/letify/__init__.py +1 -1
  3. {letify-2.4.4 → letify-2.4.5}/letify/declare/function.py +69 -11
  4. {letify-2.4.4 → letify-2.4.5}/letify/protocol/worker.py +20 -0
  5. {letify-2.4.4 → letify-2.4.5}/letify/runtime/bootstrap.py +7 -2
  6. {letify-2.4.4 → letify-2.4.5}/letify/runtime/session.py +21 -4
  7. {letify-2.4.4 → letify-2.4.5}/letify-core/Cargo.lock +3 -3
  8. {letify-2.4.4 → letify-2.4.5}/letify-core/Cargo.toml +1 -1
  9. {letify-2.4.4 → letify-2.4.5}/letify-ext/package-lock.json +2 -2
  10. {letify-2.4.4 → letify-2.4.5}/letify-ext/package.json +1 -1
  11. {letify-2.4.4 → letify-2.4.5}/pyproject.toml +1 -1
  12. {letify-2.4.4 → letify-2.4.5}/tests/test_distributed.py +62 -12
  13. {letify-2.4.4 → letify-2.4.5}/tests/test_kaggle.py +45 -0
  14. {letify-2.4.4 → letify-2.4.5}/.github/workflows/ci.yml +0 -0
  15. {letify-2.4.4 → letify-2.4.5}/.github/workflows/docs.yml +0 -0
  16. {letify-2.4.4 → letify-2.4.5}/.github/workflows/publish.yml +0 -0
  17. {letify-2.4.4 → letify-2.4.5}/.gitignore +0 -0
  18. {letify-2.4.4 → letify-2.4.5}/.researchtree +0 -0
  19. {letify-2.4.4 → letify-2.4.5}/LICENSE +0 -0
  20. {letify-2.4.4 → letify-2.4.5}/README.md +0 -0
  21. {letify-2.4.4 → letify-2.4.5}/docs/guide/01-getting-started.md +0 -0
  22. {letify-2.4.4 → letify-2.4.5}/docs/guide/02-providers.md +0 -0
  23. {letify-2.4.4 → letify-2.4.5}/docs/guide/03-execution-modes.md +0 -0
  24. {letify-2.4.4 → letify-2.4.5}/docs/guide/04-environments-and-data.md +0 -0
  25. {letify-2.4.4 → letify-2.4.5}/docs/guide/05-concurrency.md +0 -0
  26. {letify-2.4.4 → letify-2.4.5}/docs/guide/06-cost.md +0 -0
  27. {letify-2.4.4 → letify-2.4.5}/docs/guide/07-troubleshooting.md +0 -0
  28. {letify-2.4.4 → letify-2.4.5}/docs/guide/08-releasing.md +0 -0
  29. {letify-2.4.4 → letify-2.4.5}/docs/guide/README.md +0 -0
  30. {letify-2.4.4 → letify-2.4.5}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  31. {letify-2.4.4 → letify-2.4.5}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  32. {letify-2.4.4 → letify-2.4.5}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  33. {letify-2.4.4 → letify-2.4.5}/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
  34. {letify-2.4.4 → letify-2.4.5}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  35. {letify-2.4.4 → letify-2.4.5}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  36. {letify-2.4.4 → letify-2.4.5}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  37. {letify-2.4.4 → letify-2.4.5}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  38. {letify-2.4.4 → letify-2.4.5}/docs/guide/ko/README.md +0 -0
  39. {letify-2.4.4 → letify-2.4.5}/docs/locales/README_ko.md +0 -0
  40. {letify-2.4.4 → letify-2.4.5}/docs/mkdocs.yml +0 -0
  41. {letify-2.4.4 → letify-2.4.5}/examples/README.md +0 -0
  42. {letify-2.4.4 → letify-2.4.5}/examples/nvfp4-lora/.letify.example +0 -0
  43. {letify-2.4.4 → letify-2.4.5}/examples/nvfp4-lora/00_smoke.py +0 -0
  44. {letify-2.4.4 → letify-2.4.5}/examples/nvfp4-lora/01_sweep.py +0 -0
  45. {letify-2.4.4 → letify-2.4.5}/examples/nvfp4-lora/02_resume.py +0 -0
  46. {letify-2.4.4 → letify-2.4.5}/examples/nvfp4-lora/03_watch.py +0 -0
  47. {letify-2.4.4 → letify-2.4.5}/examples/nvfp4-lora/README.md +0 -0
  48. {letify-2.4.4 → letify-2.4.5}/examples/nvfp4-lora/README_ko.md +0 -0
  49. {letify-2.4.4 → letify-2.4.5}/examples/nvfp4-lora/common.py +0 -0
  50. {letify-2.4.4 → letify-2.4.5}/examples/nvfp4-lora/recipe.py +0 -0
  51. {letify-2.4.4 → letify-2.4.5}/letify/cli.py +0 -0
  52. {letify-2.4.4 → letify-2.4.5}/letify/config/__init__.py +0 -0
  53. {letify-2.4.4 → letify-2.4.5}/letify/config/inventory.py +0 -0
  54. {letify-2.4.4 → letify-2.4.5}/letify/config/login.py +0 -0
  55. {letify-2.4.4 → letify-2.4.5}/letify/config/schema.py +0 -0
  56. {letify-2.4.4 → letify-2.4.5}/letify/config/secrets.py +0 -0
  57. {letify-2.4.4 → letify-2.4.5}/letify/config/writer.py +0 -0
  58. {letify-2.4.4 → letify-2.4.5}/letify/declare/__init__.py +0 -0
  59. {letify-2.4.4 → letify-2.4.5}/letify/declare/cache.py +0 -0
  60. {letify-2.4.4 → letify-2.4.5}/letify/declare/env.py +0 -0
  61. {letify-2.4.4 → letify-2.4.5}/letify/declare/instance.py +0 -0
  62. {letify-2.4.4 → letify-2.4.5}/letify/declare/replicas.py +0 -0
  63. {letify-2.4.4 → letify-2.4.5}/letify/errors.py +0 -0
  64. {letify-2.4.4 → letify-2.4.5}/letify/install.py +0 -0
  65. {letify-2.4.4 → letify-2.4.5}/letify/launcher.py +0 -0
  66. {letify-2.4.4 → letify-2.4.5}/letify/paths.py +0 -0
  67. {letify-2.4.4 → letify-2.4.5}/letify/protocol/__init__.py +0 -0
  68. {letify-2.4.4 → letify-2.4.5}/letify/protocol/codec.py +0 -0
  69. {letify-2.4.4 → letify-2.4.5}/letify/protocol/driver.py +0 -0
  70. {letify-2.4.4 → letify-2.4.5}/letify/protocol/handle.py +0 -0
  71. {letify-2.4.4 → letify-2.4.5}/letify/protocol/wire.py +0 -0
  72. {letify-2.4.4 → letify-2.4.5}/letify/providers/__init__.py +0 -0
  73. {letify-2.4.4 → letify-2.4.5}/letify/providers/base.py +0 -0
  74. {letify-2.4.4 → letify-2.4.5}/letify/providers/colab.py +0 -0
  75. {letify-2.4.4 → letify-2.4.5}/letify/providers/colab_files.py +0 -0
  76. {letify-2.4.4 → letify-2.4.5}/letify/providers/colab_keepalive.py +0 -0
  77. {letify-2.4.4 → letify-2.4.5}/letify/providers/elice.py +0 -0
  78. {letify-2.4.4 → letify-2.4.5}/letify/providers/kaggle.py +0 -0
  79. {letify-2.4.4 → letify-2.4.5}/letify/providers/kaggle_adapter.py +0 -0
  80. {letify-2.4.4 → letify-2.4.5}/letify/providers/local.py +0 -0
  81. {letify-2.4.4 → letify-2.4.5}/letify/providers/modal.py +0 -0
  82. {letify-2.4.4 → letify-2.4.5}/letify/providers/modal_adapter.py +0 -0
  83. {letify-2.4.4 → letify-2.4.5}/letify/providers/naming.py +0 -0
  84. {letify-2.4.4 → letify-2.4.5}/letify/providers/shell.py +0 -0
  85. {letify-2.4.4 → letify-2.4.5}/letify/providers/tunnel.py +0 -0
  86. {letify-2.4.4 → letify-2.4.5}/letify/providers/usage.py +0 -0
  87. {letify-2.4.4 → letify-2.4.5}/letify/remoting/__init__.py +0 -0
  88. {letify-2.4.4 → letify-2.4.5}/letify/remoting/capability.py +0 -0
  89. {letify-2.4.4 → letify-2.4.5}/letify/remoting/device/__init__.py +0 -0
  90. {letify-2.4.4 → letify-2.4.5}/letify/remoting/device/client.py +0 -0
  91. {letify-2.4.4 → letify-2.4.5}/letify/remoting/device/cuda.py +0 -0
  92. {letify-2.4.4 → letify-2.4.5}/letify/remoting/device/executor.py +0 -0
  93. {letify-2.4.4 → letify-2.4.5}/letify/remoting/device/frames.py +0 -0
  94. {letify-2.4.4 → letify-2.4.5}/letify/remoting/device/guard.py +0 -0
  95. {letify-2.4.4 → letify-2.4.5}/letify/remoting/device/tensor.py +0 -0
  96. {letify-2.4.4 → letify-2.4.5}/letify/remoting/device/trace.py +0 -0
  97. {letify-2.4.4 → letify-2.4.5}/letify/remoting/device/value.py +0 -0
  98. {letify-2.4.4 → letify-2.4.5}/letify/remoting/loader.py +0 -0
  99. {letify-2.4.4 → letify-2.4.5}/letify/remoting/probe.py +0 -0
  100. {letify-2.4.4 → letify-2.4.5}/letify/render.py +0 -0
  101. {letify-2.4.4 → letify-2.4.5}/letify/runtime/__init__.py +0 -0
  102. {letify-2.4.4 → letify-2.4.5}/letify/runtime/channel.py +0 -0
  103. {letify-2.4.4 → letify-2.4.5}/letify/runtime/lease.py +0 -0
  104. {letify-2.4.4 → letify-2.4.5}/letify/runtime/pool.py +0 -0
  105. {letify-2.4.4 → letify-2.4.5}/letify/runtime/telemetry.py +0 -0
  106. {letify-2.4.4 → letify-2.4.5}/letify/store/__init__.py +0 -0
  107. {letify-2.4.4 → letify-2.4.5}/letify/store/backends/__init__.py +0 -0
  108. {letify-2.4.4 → letify-2.4.5}/letify/store/backends/filesystem.py +0 -0
  109. {letify-2.4.4 → letify-2.4.5}/letify/store/backends/google_auth.py +0 -0
  110. {letify-2.4.4 → letify-2.4.5}/letify/store/backends/layout.py +0 -0
  111. {letify-2.4.4 → letify-2.4.5}/letify/store/backends/objects.py +0 -0
  112. {letify-2.4.4 → letify-2.4.5}/letify/store/cas.py +0 -0
  113. {letify-2.4.4 → letify-2.4.5}/letify/store/pathdata.py +0 -0
  114. {letify-2.4.4 → letify-2.4.5}/letify/store/sendorder.py +0 -0
  115. {letify-2.4.4 → letify-2.4.5}/letify/store/volume.py +0 -0
  116. {letify-2.4.4 → letify-2.4.5}/letify/stubs.py +0 -0
  117. {letify-2.4.4 → letify-2.4.5}/letify/tools.py +0 -0
  118. {letify-2.4.4 → letify-2.4.5}/letify/transport/__init__.py +0 -0
  119. {letify-2.4.4 → letify-2.4.5}/letify/transport/agent.py +0 -0
  120. {letify-2.4.4 → letify-2.4.5}/letify/transport/announce.py +0 -0
  121. {letify-2.4.4 → letify-2.4.5}/letify/transport/link.py +0 -0
  122. {letify-2.4.4 → letify-2.4.5}/letify/transport/nat.py +0 -0
  123. {letify-2.4.4 → letify-2.4.5}/letify/transport/pipeline.py +0 -0
  124. {letify-2.4.4 → letify-2.4.5}/letify/transport/portmap.py +0 -0
  125. {letify-2.4.4 → letify-2.4.5}/letify/transport/probe.py +0 -0
  126. {letify-2.4.4 → letify-2.4.5}/letify/transport/quic.py +0 -0
  127. {letify-2.4.4 → letify-2.4.5}/letify/transport/rendezvous.py +0 -0
  128. {letify-2.4.4 → letify-2.4.5}/letify/transport/setup.py +0 -0
  129. {letify-2.4.4 → letify-2.4.5}/letify/transport/sshopts.py +0 -0
  130. {letify-2.4.4 → letify-2.4.5}/letify/transport/strategies.py +0 -0
  131. {letify-2.4.4 → letify-2.4.5}/letify-core/.gitignore +0 -0
  132. {letify-2.4.4 → letify-2.4.5}/letify-core/build.py +0 -0
  133. {letify-2.4.4 → letify-2.4.5}/letify-core/letify-agent/Cargo.toml +0 -0
  134. {letify-2.4.4 → letify-2.4.5}/letify-core/letify-agent/src/driver.rs +0 -0
  135. {letify-2.4.4 → letify-2.4.5}/letify-core/letify-agent/src/main.rs +0 -0
  136. {letify-2.4.4 → letify-2.4.5}/letify-core/letify-driver/Cargo.toml +0 -0
  137. {letify-2.4.4 → letify-2.4.5}/letify-core/letify-driver/src/client.rs +0 -0
  138. {letify-2.4.4 → letify-2.4.5}/letify-core/letify-driver/src/exports.rs +0 -0
  139. {letify-2.4.4 → letify-2.4.5}/letify-core/letify-driver/src/image.rs +0 -0
  140. {letify-2.4.4 → letify-2.4.5}/letify-core/letify-driver/src/lib.rs +0 -0
  141. {letify-2.4.4 → letify-2.4.5}/letify-core/letify-driver/src/status.rs +0 -0
  142. {letify-2.4.4 → letify-2.4.5}/letify-core/letify-driver/src/table.rs +0 -0
  143. {letify-2.4.4 → letify-2.4.5}/letify-core/letify-quic/Cargo.toml +0 -0
  144. {letify-2.4.4 → letify-2.4.5}/letify-core/letify-quic/src/main.rs +0 -0
  145. {letify-2.4.4 → letify-2.4.5}/letify-core/letify-wire/Cargo.toml +0 -0
  146. {letify-2.4.4 → letify-2.4.5}/letify-core/letify-wire/src/lib.rs +0 -0
  147. {letify-2.4.4 → letify-2.4.5}/letify-core/letify-wire/tests/throughput.rs +0 -0
  148. {letify-2.4.4 → letify-2.4.5}/letify-core/rust-toolchain.toml +0 -0
  149. {letify-2.4.4 → letify-2.4.5}/letify-ext/.vscodeignore +0 -0
  150. {letify-2.4.4 → letify-2.4.5}/letify-ext/LICENSE +0 -0
  151. {letify-2.4.4 → letify-2.4.5}/letify-ext/README.md +0 -0
  152. {letify-2.4.4 → letify-2.4.5}/letify-ext/media/icon.svg +0 -0
  153. {letify-2.4.4 → letify-2.4.5}/letify-ext/src/cli.ts +0 -0
  154. {letify-2.4.4 → letify-2.4.5}/letify-ext/src/extension.ts +0 -0
  155. {letify-2.4.4 → letify-2.4.5}/letify-ext/src/history.ts +0 -0
  156. {letify-2.4.4 → letify-2.4.5}/letify-ext/src/model.ts +0 -0
  157. {letify-2.4.4 → letify-2.4.5}/letify-ext/src/poll.ts +0 -0
  158. {letify-2.4.4 → letify-2.4.5}/letify-ext/src/render.ts +0 -0
  159. {letify-2.4.4 → letify-2.4.5}/letify-ext/test/cli.test.ts +0 -0
  160. {letify-2.4.4 → letify-2.4.5}/letify-ext/test/manifest.test.ts +0 -0
  161. {letify-2.4.4 → letify-2.4.5}/letify-ext/test/model.test.ts +0 -0
  162. {letify-2.4.4 → letify-2.4.5}/letify-ext/test/regressions.test.ts +0 -0
  163. {letify-2.4.4 → letify-2.4.5}/letify-ext/tsconfig.json +0 -0
  164. {letify-2.4.4 → letify-2.4.5}/letify_providers.py +0 -0
  165. {letify-2.4.4 → letify-2.4.5}/scripts/sync-main.sh +0 -0
  166. {letify-2.4.4 → letify-2.4.5}/scripts/version.py +0 -0
  167. {letify-2.4.4 → letify-2.4.5}/tests/conftest.py +0 -0
  168. {letify-2.4.4 → letify-2.4.5}/tests/fake_kaggle_adapter.py +0 -0
  169. {letify-2.4.4 → letify-2.4.5}/tests/fake_modal_adapter.py +0 -0
  170. {letify-2.4.4 → letify-2.4.5}/tests/test_cli.py +0 -0
  171. {letify-2.4.4 → letify-2.4.5}/tests/test_cli_output.py +0 -0
  172. {letify-2.4.4 → letify-2.4.5}/tests/test_colab_keepalive.py +0 -0
  173. {letify-2.4.4 → letify-2.4.5}/tests/test_config.py +0 -0
  174. {letify-2.4.4 → letify-2.4.5}/tests/test_core.py +0 -0
  175. {letify-2.4.4 → letify-2.4.5}/tests/test_data.py +0 -0
  176. {letify-2.4.4 → letify-2.4.5}/tests/test_data_streaming.py +0 -0
  177. {letify-2.4.4 → letify-2.4.5}/tests/test_declare.py +0 -0
  178. {letify-2.4.4 → letify-2.4.5}/tests/test_device.py +0 -0
  179. {letify-2.4.4 → letify-2.4.5}/tests/test_editor_sessions.py +0 -0
  180. {letify-2.4.4 → letify-2.4.5}/tests/test_elice.py +0 -0
  181. {letify-2.4.4 → letify-2.4.5}/tests/test_frames.py +0 -0
  182. {letify-2.4.4 → letify-2.4.5}/tests/test_install.py +0 -0
  183. {letify-2.4.4 → letify-2.4.5}/tests/test_kaggle_cookie.py +0 -0
  184. {letify-2.4.4 → letify-2.4.5}/tests/test_kaggle_link.py +0 -0
  185. {letify-2.4.4 → letify-2.4.5}/tests/test_link_log.py +0 -0
  186. {letify-2.4.4 → letify-2.4.5}/tests/test_notebook.py +0 -0
  187. {letify-2.4.4 → letify-2.4.5}/tests/test_parallel_placement.py +0 -0
  188. {letify-2.4.4 → letify-2.4.5}/tests/test_paths.py +0 -0
  189. {letify-2.4.4 → letify-2.4.5}/tests/test_portmap.py +0 -0
  190. {letify-2.4.4 → letify-2.4.5}/tests/test_protocol.py +0 -0
  191. {letify-2.4.4 → letify-2.4.5}/tests/test_providers.py +0 -0
  192. {letify-2.4.4 → letify-2.4.5}/tests/test_quic.py +0 -0
  193. {letify-2.4.4 → letify-2.4.5}/tests/test_remoting.py +0 -0
  194. {letify-2.4.4 → letify-2.4.5}/tests/test_render.py +0 -0
  195. {letify-2.4.4 → letify-2.4.5}/tests/test_runtime.py +0 -0
  196. {letify-2.4.4 → letify-2.4.5}/tests/test_ship.py +0 -0
  197. {letify-2.4.4 → letify-2.4.5}/tests/test_store.py +0 -0
  198. {letify-2.4.4 → letify-2.4.5}/tests/test_strategies.py +0 -0
  199. {letify-2.4.4 → letify-2.4.5}/tests/test_stubs.py +0 -0
  200. {letify-2.4.4 → letify-2.4.5}/tests/test_transport.py +0 -0
  201. {letify-2.4.4 → letify-2.4.5}/tests/test_tunnel_setup.py +0 -0
  202. {letify-2.4.4 → letify-2.4.5}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.4.4
3
+ Version: 2.4.5
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
@@ -64,7 +64,7 @@ distributed: Final = Host.distributed
64
64
  # The enum class stays importable from letify.declare.instance for letify's own use.
65
65
  del Host
66
66
 
67
- __version__ = "2.4.4"
67
+ __version__ = "2.4.5"
68
68
 
69
69
 
70
70
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -65,8 +65,11 @@ class Function(Generic[R]):
65
65
  self.data_order = data_order
66
66
  self.data_first_wave = data_first_wave
67
67
  self.is_async = inspect.iscoroutinefunction(fn)
68
- #: Spec "Replicas": one Instance per replica, in the order the list named them.
69
- self.replicas = _replicas(device, self.host)
68
+ #: Spec "Replicas": one Instance per replica, in the order the list named them,
69
+ #: each carrying the declared placement so a provider is not told it is local.
70
+ self.replicas = tuple(
71
+ instance._placed(self.host) for instance in _replicas(device, self.host)
72
+ )
70
73
  self.device = self._place(self.replicas[0] if self.replicas else device)
71
74
  update_wrapper(self, fn)
72
75
 
@@ -139,18 +142,21 @@ class Function(Generic[R]):
139
142
  def _run(self, args: tuple, kwargs: dict) -> Any:
140
143
  launcher = self.launcher
141
144
  if self.host is Host.distributed:
142
- # Spec "Replicas": the placement is declared and the runner is not written
143
- # yet. Running the first replica alone would bill one runtime for a
144
- # declaration that asked for several, without saying so.
145
- raise UnsupportedMode(
146
- f"host=letify.distributed declares {len(self.replicas)} replicas and "
147
- f"letify cannot run them yet, so the call is refused rather than run on "
148
- f"one of them. Use host=letify.remote with a single device until it is "
149
- f"finished."
150
- )
145
+ return self._run_replicas(args, kwargs)
151
146
  instance = launcher.resolve(self.device)
152
147
  if instance.placement is Host.local:
153
148
  return self._run_here(instance, args, kwargs)
149
+ return self._run_one(instance, args, kwargs)
150
+
151
+ def _run_one(
152
+ self, instance: Any, args: tuple, kwargs: dict, replica: dict | None = None
153
+ ) -> Any:
154
+ """Run the body on one runtime, retrying on an infrastructure failure.
155
+
156
+ One replica of a distributed call runs through here too, so a replica gets the
157
+ same retries and the same reporting as a call that named a single device.
158
+ """
159
+ launcher = self.launcher
154
160
  last: Exception | None = None
155
161
 
156
162
  for attempt in range(self.retries + 1):
@@ -166,6 +172,7 @@ class Function(Generic[R]):
166
172
  timeout=self.timeout,
167
173
  data_order=self.data_order,
168
174
  data_first_wave=self.data_first_wave,
175
+ replica=_with_rate(replica, runtime),
169
176
  )
170
177
  except (RuntimeFailure, ProtocolError) as failure:
171
178
  # The session misbehaved rather than the user's code, so this runtime
@@ -200,6 +207,40 @@ class Function(Generic[R]):
200
207
 
201
208
  raise RuntimeLost(str(last)) # pragma: no cover
202
209
 
210
+ def _run_replicas(self, args: tuple, kwargs: dict) -> list:
211
+ """Run the body once per replica, and answer with their values in rank order.
212
+
213
+ Spec "Replicas": each replica is told which one it is, the call waits for all of
214
+ them, and a replica that fails fails the call, as a single remote call's failure
215
+ does. The replicas run at the same time, because running them in turn would make
216
+ a reduction between them impossible.
217
+ """
218
+ import threading
219
+
220
+ world = len(self.replicas)
221
+ values: list = [None] * world
222
+ failures: list[BaseException] = []
223
+
224
+ def run(rank: int, instance: Any) -> None:
225
+ try:
226
+ values[rank] = self._run_one(
227
+ instance, args, kwargs, {"rank": rank, "world_size": world}
228
+ )
229
+ except BaseException as exc: # reported after every replica has stopped
230
+ failures.append(exc)
231
+
232
+ threads = [
233
+ threading.Thread(target=run, args=(rank, instance), daemon=True)
234
+ for rank, instance in enumerate(self.replicas)
235
+ ]
236
+ for thread in threads:
237
+ thread.start()
238
+ for thread in threads:
239
+ thread.join()
240
+ if failures:
241
+ raise failures[0]
242
+ return values
243
+
203
244
  def _run_here(self, instance: Any, args: tuple, kwargs: dict) -> Any:
204
245
  """Run the body in this process with its PyTorch operators on the runtime's device.
205
246
 
@@ -261,6 +302,23 @@ def _report_deferred(delta: Any) -> None:
261
302
  )
262
303
 
263
304
 
305
+ def _with_rate(replica: dict | None, runtime: Any) -> dict | None:
306
+ """The replica's numbers plus the rate its own link measured, for ``local_steps``.
307
+
308
+ Spec "How often to synchronize": the rate is the quantity only letify has, and it is
309
+ this replica's link rather than the job's, so it travels with the call.
310
+ """
311
+ if replica is None:
312
+ return None
313
+ rate = None
314
+ try:
315
+ link = runtime.provider.link(runtime)
316
+ rate = getattr(link, "upload_bps", None)
317
+ except Exception:
318
+ rate = None
319
+ return {**replica, "rate_bps": rate}
320
+
321
+
264
322
  def _replicas(device: Any, host: Host) -> tuple[Instance, ...]:
265
323
  """The replicas a declaration names, or empty when it names a single device.
266
324
 
@@ -316,6 +316,7 @@ def _reply(stream, outcome):
316
316
 
317
317
  def _op_call(request):
318
318
  data = request.pop("data", None)
319
+ _letify_replica(request.pop("replica", None))
319
320
  modules_dir = request.pop("modules_dir", None)
320
321
  if modules_dir:
321
322
  modules_dir = os.path.expanduser(modules_dir)
@@ -349,6 +350,25 @@ def _op_call(request):
349
350
  _DATA_WRITTEN = {}
350
351
 
351
352
 
353
+ def _letify_replica(replica):
354
+ """Tell the letify in this runtime which replica this call is, or that it is none.
355
+
356
+ Spec "Replicas": the body reads its rank through letify, which is installed here by
357
+ the environment, so the numbers are set on the module rather than passed through the
358
+ user's signature.
359
+ """
360
+ try:
361
+ from letify.declare import replicas as _replicas
362
+ except Exception:
363
+ return
364
+ if replica is None:
365
+ _replicas._WHO.rank = _replicas._WHO.world_size = _replicas._WHO.rate_bps = None
366
+ return
367
+ _replicas._WHO.rank = replica.get("rank")
368
+ _replicas._WHO.world_size = replica.get("world_size")
369
+ _replicas._WHO.rate_bps = replica.get("rate_bps")
370
+
371
+
352
372
  def _data_hash_file(path):
353
373
  """The file digest of a file on the runtime: blake3 when present, else blake2b."""
354
374
  hasher = _data_hasher()
@@ -133,7 +133,7 @@ def project_dir(workspace_root: str, env: Env) -> str:
133
133
  #: Reads the cards a runtime has, without nvidia-smi, which a Kaggle image lacks on PATH.
134
134
  #: Spec "Kaggle", the accelerators.
135
135
  CARD_SOURCE = (
136
- "import glob\n"
136
+ "import glob, os\n"
137
137
  "__letify_cards = []\n"
138
138
  "for _letify_path in sorted(glob.glob('/proc/driver/nvidia/gpus/*/information')):\n"
139
139
  " try:\n"
@@ -144,7 +144,12 @@ CARD_SOURCE = (
144
144
  " break\n"
145
145
  " except OSError:\n"
146
146
  " pass\n"
147
- "__letify_value__ = __letify_cards\n"
147
+ # /proc can list the right cards on a session whose driver cannot be used, and the
148
+ # first .to('cuda') then raises minutes into the call. Spec "Kaggle".
149
+ "__letify_value__ = {\n"
150
+ " 'cards': __letify_cards,\n"
151
+ " 'driver': os.path.exists('/dev/nvidiactl'),\n"
152
+ "}\n"
148
153
  )
149
154
 
150
155
 
@@ -269,6 +269,7 @@ class Runtime:
269
269
  timeout: float | None = None,
270
270
  data_order: Any = None,
271
271
  data_first_wave: int | None = None,
272
+ replica: dict[str, Any] | None = None,
272
273
  ) -> tuple[Any, str]:
273
274
  """Run one declared function inside this runtime.
274
275
 
@@ -283,7 +284,9 @@ class Runtime:
283
284
  fn, args, kwargs, timeout=timeout, modules=self.env.ship_modules
284
285
  )
285
286
  args, kwargs = self._externalize(args, kwargs)
286
- return self._call_with_data(fn, args, kwargs, timeout, data_order, data_first_wave)
287
+ return self._call_with_data(
288
+ fn, args, kwargs, timeout, data_order, data_first_wave, replica
289
+ )
287
290
 
288
291
  def _call_with_data(
289
292
  self,
@@ -293,6 +296,7 @@ class Runtime:
293
296
  timeout: float | None,
294
297
  data_order: Any = None,
295
298
  data_first_wave: int | None = None,
299
+ replica: dict[str, Any] | None = None,
296
300
  ) -> tuple[Any, str]:
297
301
  """Pickle a call, place the first wave of its data, and send the rest beside it.
298
302
 
@@ -317,6 +321,10 @@ class Runtime:
317
321
  ship_digests.add(digest)
318
322
  head, buffers = protocol.dumps_call_parts(fn, args, kwargs, data=collector)
319
323
  request: dict[str, Any] = {"op": "call", "payload": head, "buffers": buffers}
324
+ if replica is not None:
325
+ # Spec "Replicas": the body reads its rank on the runtime, not here, so the
326
+ # numbers travel with the call rather than being set on a client thread.
327
+ request["replica"] = replica
320
328
  blobs = f"{root}/data/blobs"
321
329
  stream: pathdata.Stream | None = None
322
330
  if self.env.ship_modules:
@@ -708,16 +716,25 @@ class Runtime:
708
716
  return
709
717
  fragment, count = expected
710
718
  try:
711
- cards = self.eval(bootstrap.CARD_SOURCE, timeout=120)
719
+ found = self.eval(bootstrap.CARD_SOURCE, timeout=120)
712
720
  except Exception:
713
721
  # Reading /proc is not worth failing a session over when it cannot be read.
714
722
  return
715
- cards = [str(card) for card in cards or ()]
723
+ found = found if isinstance(found, dict) else {"cards": found, "driver": True}
724
+ cards = [str(card) for card in found.get("cards") or ()]
725
+ driver = bool(found.get("driver"))
716
726
  matched = [card for card in cards if fragment.casefold() in card.casefold()]
717
- if len(matched) == count and len(cards) == count:
727
+ if len(matched) == count and len(cards) == count and driver:
718
728
  return
719
729
  asked = getattr(instance, "accelerator", None) or fragment
720
730
  arrived = ", ".join(cards) if cards else "no GPU at all"
731
+ if cards and not driver:
732
+ # Spec "Kaggle": the cards are right and the driver cannot be used, which the
733
+ # first .to("cuda") would have reported minutes into the call instead.
734
+ arrived = (
735
+ f"{arrived}, and no usable driver: /dev/nvidiactl is not there, so CUDA "
736
+ f"cannot start"
737
+ )
721
738
  message = (
722
739
  f"{self.name}: {self.provider.alias} was asked for {count} x {asked} and the "
723
740
  f"session has {arrived}. A declaration says where a function runs, so the "
@@ -138,7 +138,7 @@ dependencies = [
138
138
 
139
139
  [[package]]
140
140
  name = "letify-agent"
141
- version = "2.4.4"
141
+ version = "2.4.5"
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.4.4"
149
+ version = "2.4.5"
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.4.4"
167
+ version = "2.4.5"
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.4.4"
11
+ version = "2.4.5"
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.4.4",
3
+ "version": "2.4.5",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "letify-status",
9
- "version": "2.4.4",
9
+ "version": "2.4.5",
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.4.4",
5
+ "version": "2.4.5",
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.4.4"
3
+ version = "2.4.5"
4
4
  description = "Declarations that become infrastructure."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -127,23 +127,73 @@ def test_local_steps_refuses_a_link_that_was_never_measured() -> None:
127
127
  assert "probe" in str(failure.value)
128
128
 
129
129
 
130
- def test_calling_a_distributed_declaration_says_it_is_not_finished(launcher_from) -> None:
131
- """Spec "Replicas": the placement is declared and the runner is not written yet.
130
+ def test_every_replica_runs_the_body_and_knows_which_one_it_is(launcher_from) -> None:
131
+ """Spec "Replicas": the same body once per device, and the values come back in rank order."""
132
+ let = launcher_from('[a]\nkind = "local"\n\n[b]\nkind = "local"\n')
133
+
134
+ @let.function(
135
+ device=[let.providers.a.CPU, let.providers.b.CPU], host=letify.distributed
136
+ )
137
+ def who() -> tuple[int, int]:
138
+ import letify as inner
139
+
140
+ return inner.rank(), inner.world_size()
141
+
142
+ assert who() == [(0, 2), (1, 2)]
143
+
144
+
145
+ def test_one_replica_still_comes_back_as_a_list(launcher_from) -> None:
146
+ """Spec "Replicas": a single instance is one replica, so the shape does not change."""
147
+ let = launcher_from('[a]\nkind = "local"\n')
148
+
149
+ @let.function(device=let.providers.a.CPU, host=letify.distributed)
150
+ def who() -> tuple[int, int]:
151
+ import letify as inner
152
+
153
+ return inner.rank(), inner.world_size()
154
+
155
+ assert who() == [(0, 1)]
156
+
132
157
 
133
- Accepting the declaration and then running on the first replica alone would be worse
134
- than refusing: the user asked for several and would be billed for one, silently.
158
+ def test_a_replica_that_fails_fails_the_call(launcher_from) -> None:
159
+ """Spec "Replicas": a replica that cannot run fails the call, as one remote call does."""
160
+ let = launcher_from('[a]\nkind = "local"\n\n[b]\nkind = "local"\n')
161
+
162
+ @let.function(
163
+ device=[let.providers.a.CPU, let.providers.b.CPU], host=letify.distributed
164
+ )
165
+ def breaks() -> int:
166
+ import letify as inner
167
+
168
+ if inner.rank() == 1:
169
+ raise ValueError("the second replica refused")
170
+ return 0
171
+
172
+ with pytest.raises(letify.RemoteError) as failure:
173
+ breaks()
174
+ assert "the second replica refused" in str(failure.value)
175
+
176
+
177
+ def test_a_replica_does_not_need_torch_on_this_machine(launcher_from, no_module) -> None:
178
+ """Spec "Replicas": the placement is not local, so nothing asks for PyTorch here.
179
+
180
+ The replicas were handed to the provider without the declared placement folded in,
181
+ so their placement read as the default, local, and `check_mode` asked for PyTorch on
182
+ the client. It passed on a machine that has torch and failed on CI, which does not.
135
183
  """
136
184
  let = launcher_from('[a]\nkind = "local"\n\n[b]\nkind = "local"\n')
137
185
 
138
186
  @let.function(
139
187
  device=[let.providers.a.CPU, let.providers.b.CPU], host=letify.distributed
140
188
  )
141
- def body() -> int:
142
- return 1
189
+ def who() -> int:
190
+ import letify as inner
143
191
 
144
- with pytest.raises(letify.UnsupportedMode) as failure:
145
- body()
146
- message = str(failure.value)
147
- assert "cannot run them yet" in message
148
- assert "2 replicas" in message
149
- assert "letify.remote" in message
192
+ return inner.rank()
193
+
194
+ no_module("torch")
195
+ assert who() == [0, 1]
196
+ assert [instance.placement for instance in who.replicas] == [
197
+ letify.distributed,
198
+ letify.distributed,
199
+ ]
@@ -1451,3 +1451,48 @@ def test_a_refused_run_says_how_long_ago_the_last_session_was_made(
1451
1451
  assert "too soon" in detail
1452
1452
  path.write_text("", encoding="utf-8")
1453
1453
  assert refusal_detail("kaggle_a") == ""
1454
+
1455
+
1456
+ def test_a_session_with_the_cards_and_no_driver_is_refused() -> None:
1457
+ """Spec "Kaggle": the cards can be listed on a session whose driver cannot be used.
1458
+
1459
+ A live T4 session passed the card check and then raised "Found no NVIDIA driver on
1460
+ your system" at the first .to("cuda"), minutes into the call.
1461
+ """
1462
+ from letify.errors import InsufficientDevices
1463
+ from letify.runtime.session import Runtime
1464
+
1465
+ provider = kaggle_provider()
1466
+ session = object.__new__(Runtime)
1467
+ session.name = "letify-t4-fc1bf0"
1468
+ session.provider = provider
1469
+ session.instance = type("I", (), {"gpu": "T4", "accelerator": "T4"})()
1470
+ answer = {"cards": ["Tesla T4", "Tesla T4"], "driver": False}
1471
+ monkey = lambda self, source, timeout=None: answer # noqa: E731
1472
+ Runtime.eval, original = monkey, Runtime.eval
1473
+ try:
1474
+ with pytest.raises(InsufficientDevices) as failure:
1475
+ session.check_devices()
1476
+ finally:
1477
+ Runtime.eval = original
1478
+ message = str(failure.value)
1479
+ assert "driver" in message
1480
+ assert "Tesla T4" in message
1481
+
1482
+
1483
+ def test_a_session_with_the_cards_and_a_driver_passes() -> None:
1484
+ """Spec: both halves have to hold, and then the session is used."""
1485
+ from letify.runtime.session import Runtime
1486
+
1487
+ provider = kaggle_provider()
1488
+ session = object.__new__(Runtime)
1489
+ session.name = "letify-t4-ok"
1490
+ session.provider = provider
1491
+ session.instance = type("I", (), {"gpu": "T4", "accelerator": "T4"})()
1492
+ answer = {"cards": ["Tesla T4", "Tesla T4"], "driver": True}
1493
+ monkey = lambda self, source, timeout=None: answer # noqa: E731
1494
+ Runtime.eval, original = monkey, Runtime.eval
1495
+ try:
1496
+ session.check_devices()
1497
+ finally:
1498
+ Runtime.eval = original
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes