letify 1.2.3__tar.gz → 1.2.4__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 (192) hide show
  1. {letify-1.2.3 → letify-1.2.4}/PKG-INFO +1 -1
  2. {letify-1.2.3 → letify-1.2.4}/letify/__init__.py +1 -1
  3. {letify-1.2.3 → letify-1.2.4}/letify/transport/pipeline.py +90 -35
  4. {letify-1.2.3 → letify-1.2.4}/letify-core/Cargo.lock +3 -3
  5. {letify-1.2.3 → letify-1.2.4}/letify-core/Cargo.toml +1 -1
  6. {letify-1.2.3 → letify-1.2.4}/letify-ext/package-lock.json +2 -2
  7. {letify-1.2.3 → letify-1.2.4}/letify-ext/package.json +1 -1
  8. {letify-1.2.3 → letify-1.2.4}/pyproject.toml +1 -1
  9. {letify-1.2.3 → letify-1.2.4}/tests/test_link_log.py +31 -0
  10. {letify-1.2.3 → letify-1.2.4}/.github/workflows/ci.yml +0 -0
  11. {letify-1.2.3 → letify-1.2.4}/.github/workflows/docs.yml +0 -0
  12. {letify-1.2.3 → letify-1.2.4}/.github/workflows/publish.yml +0 -0
  13. {letify-1.2.3 → letify-1.2.4}/.gitignore +0 -0
  14. {letify-1.2.3 → letify-1.2.4}/.researchtree +0 -0
  15. {letify-1.2.3 → letify-1.2.4}/LICENSE +0 -0
  16. {letify-1.2.3 → letify-1.2.4}/README.md +0 -0
  17. {letify-1.2.3 → letify-1.2.4}/docs/guide/01-getting-started.md +0 -0
  18. {letify-1.2.3 → letify-1.2.4}/docs/guide/02-providers.md +0 -0
  19. {letify-1.2.3 → letify-1.2.4}/docs/guide/03-execution-modes.md +0 -0
  20. {letify-1.2.3 → letify-1.2.4}/docs/guide/04-environments-and-data.md +0 -0
  21. {letify-1.2.3 → letify-1.2.4}/docs/guide/05-concurrency.md +0 -0
  22. {letify-1.2.3 → letify-1.2.4}/docs/guide/06-cost.md +0 -0
  23. {letify-1.2.3 → letify-1.2.4}/docs/guide/07-troubleshooting.md +0 -0
  24. {letify-1.2.3 → letify-1.2.4}/docs/guide/08-releasing.md +0 -0
  25. {letify-1.2.3 → letify-1.2.4}/docs/guide/README.md +0 -0
  26. {letify-1.2.3 → letify-1.2.4}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  27. {letify-1.2.3 → letify-1.2.4}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  28. {letify-1.2.3 → letify-1.2.4}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  29. {letify-1.2.3 → letify-1.2.4}/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
  30. {letify-1.2.3 → letify-1.2.4}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  31. {letify-1.2.3 → letify-1.2.4}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  32. {letify-1.2.3 → letify-1.2.4}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  33. {letify-1.2.3 → letify-1.2.4}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  34. {letify-1.2.3 → letify-1.2.4}/docs/guide/ko/README.md +0 -0
  35. {letify-1.2.3 → letify-1.2.4}/docs/locales/README_ko.md +0 -0
  36. {letify-1.2.3 → letify-1.2.4}/docs/mkdocs.yml +0 -0
  37. {letify-1.2.3 → letify-1.2.4}/examples/README.md +0 -0
  38. {letify-1.2.3 → letify-1.2.4}/examples/nvfp4-lora/.letify.example +0 -0
  39. {letify-1.2.3 → letify-1.2.4}/examples/nvfp4-lora/00_smoke.py +0 -0
  40. {letify-1.2.3 → letify-1.2.4}/examples/nvfp4-lora/01_sweep.py +0 -0
  41. {letify-1.2.3 → letify-1.2.4}/examples/nvfp4-lora/02_resume.py +0 -0
  42. {letify-1.2.3 → letify-1.2.4}/examples/nvfp4-lora/03_watch.py +0 -0
  43. {letify-1.2.3 → letify-1.2.4}/examples/nvfp4-lora/README.md +0 -0
  44. {letify-1.2.3 → letify-1.2.4}/examples/nvfp4-lora/README_ko.md +0 -0
  45. {letify-1.2.3 → letify-1.2.4}/examples/nvfp4-lora/common.py +0 -0
  46. {letify-1.2.3 → letify-1.2.4}/examples/nvfp4-lora/recipe.py +0 -0
  47. {letify-1.2.3 → letify-1.2.4}/letify/cli.py +0 -0
  48. {letify-1.2.3 → letify-1.2.4}/letify/config/__init__.py +0 -0
  49. {letify-1.2.3 → letify-1.2.4}/letify/config/inventory.py +0 -0
  50. {letify-1.2.3 → letify-1.2.4}/letify/config/login.py +0 -0
  51. {letify-1.2.3 → letify-1.2.4}/letify/config/schema.py +0 -0
  52. {letify-1.2.3 → letify-1.2.4}/letify/config/secrets.py +0 -0
  53. {letify-1.2.3 → letify-1.2.4}/letify/config/writer.py +0 -0
  54. {letify-1.2.3 → letify-1.2.4}/letify/declare/__init__.py +0 -0
  55. {letify-1.2.3 → letify-1.2.4}/letify/declare/cache.py +0 -0
  56. {letify-1.2.3 → letify-1.2.4}/letify/declare/env.py +0 -0
  57. {letify-1.2.3 → letify-1.2.4}/letify/declare/function.py +0 -0
  58. {letify-1.2.3 → letify-1.2.4}/letify/declare/instance.py +0 -0
  59. {letify-1.2.3 → letify-1.2.4}/letify/errors.py +0 -0
  60. {letify-1.2.3 → letify-1.2.4}/letify/install.py +0 -0
  61. {letify-1.2.3 → letify-1.2.4}/letify/launcher.py +0 -0
  62. {letify-1.2.3 → letify-1.2.4}/letify/paths.py +0 -0
  63. {letify-1.2.3 → letify-1.2.4}/letify/protocol/__init__.py +0 -0
  64. {letify-1.2.3 → letify-1.2.4}/letify/protocol/codec.py +0 -0
  65. {letify-1.2.3 → letify-1.2.4}/letify/protocol/driver.py +0 -0
  66. {letify-1.2.3 → letify-1.2.4}/letify/protocol/handle.py +0 -0
  67. {letify-1.2.3 → letify-1.2.4}/letify/protocol/wire.py +0 -0
  68. {letify-1.2.3 → letify-1.2.4}/letify/protocol/worker.py +0 -0
  69. {letify-1.2.3 → letify-1.2.4}/letify/providers/__init__.py +0 -0
  70. {letify-1.2.3 → letify-1.2.4}/letify/providers/base.py +0 -0
  71. {letify-1.2.3 → letify-1.2.4}/letify/providers/colab.py +0 -0
  72. {letify-1.2.3 → letify-1.2.4}/letify/providers/colab_files.py +0 -0
  73. {letify-1.2.3 → letify-1.2.4}/letify/providers/colab_keepalive.py +0 -0
  74. {letify-1.2.3 → letify-1.2.4}/letify/providers/elice.py +0 -0
  75. {letify-1.2.3 → letify-1.2.4}/letify/providers/kaggle.py +0 -0
  76. {letify-1.2.3 → letify-1.2.4}/letify/providers/kaggle_adapter.py +0 -0
  77. {letify-1.2.3 → letify-1.2.4}/letify/providers/local.py +0 -0
  78. {letify-1.2.3 → letify-1.2.4}/letify/providers/modal.py +0 -0
  79. {letify-1.2.3 → letify-1.2.4}/letify/providers/modal_adapter.py +0 -0
  80. {letify-1.2.3 → letify-1.2.4}/letify/providers/naming.py +0 -0
  81. {letify-1.2.3 → letify-1.2.4}/letify/providers/shell.py +0 -0
  82. {letify-1.2.3 → letify-1.2.4}/letify/providers/tunnel.py +0 -0
  83. {letify-1.2.3 → letify-1.2.4}/letify/providers/usage.py +0 -0
  84. {letify-1.2.3 → letify-1.2.4}/letify/remoting/__init__.py +0 -0
  85. {letify-1.2.3 → letify-1.2.4}/letify/remoting/capability.py +0 -0
  86. {letify-1.2.3 → letify-1.2.4}/letify/remoting/device/__init__.py +0 -0
  87. {letify-1.2.3 → letify-1.2.4}/letify/remoting/device/client.py +0 -0
  88. {letify-1.2.3 → letify-1.2.4}/letify/remoting/device/cuda.py +0 -0
  89. {letify-1.2.3 → letify-1.2.4}/letify/remoting/device/executor.py +0 -0
  90. {letify-1.2.3 → letify-1.2.4}/letify/remoting/device/frames.py +0 -0
  91. {letify-1.2.3 → letify-1.2.4}/letify/remoting/device/guard.py +0 -0
  92. {letify-1.2.3 → letify-1.2.4}/letify/remoting/device/tensor.py +0 -0
  93. {letify-1.2.3 → letify-1.2.4}/letify/remoting/device/trace.py +0 -0
  94. {letify-1.2.3 → letify-1.2.4}/letify/remoting/device/value.py +0 -0
  95. {letify-1.2.3 → letify-1.2.4}/letify/remoting/loader.py +0 -0
  96. {letify-1.2.3 → letify-1.2.4}/letify/remoting/probe.py +0 -0
  97. {letify-1.2.3 → letify-1.2.4}/letify/render.py +0 -0
  98. {letify-1.2.3 → letify-1.2.4}/letify/runtime/__init__.py +0 -0
  99. {letify-1.2.3 → letify-1.2.4}/letify/runtime/bootstrap.py +0 -0
  100. {letify-1.2.3 → letify-1.2.4}/letify/runtime/channel.py +0 -0
  101. {letify-1.2.3 → letify-1.2.4}/letify/runtime/lease.py +0 -0
  102. {letify-1.2.3 → letify-1.2.4}/letify/runtime/pool.py +0 -0
  103. {letify-1.2.3 → letify-1.2.4}/letify/runtime/session.py +0 -0
  104. {letify-1.2.3 → letify-1.2.4}/letify/runtime/telemetry.py +0 -0
  105. {letify-1.2.3 → letify-1.2.4}/letify/store/__init__.py +0 -0
  106. {letify-1.2.3 → letify-1.2.4}/letify/store/backends/__init__.py +0 -0
  107. {letify-1.2.3 → letify-1.2.4}/letify/store/backends/filesystem.py +0 -0
  108. {letify-1.2.3 → letify-1.2.4}/letify/store/backends/google_auth.py +0 -0
  109. {letify-1.2.3 → letify-1.2.4}/letify/store/backends/layout.py +0 -0
  110. {letify-1.2.3 → letify-1.2.4}/letify/store/backends/objects.py +0 -0
  111. {letify-1.2.3 → letify-1.2.4}/letify/store/cas.py +0 -0
  112. {letify-1.2.3 → letify-1.2.4}/letify/store/pathdata.py +0 -0
  113. {letify-1.2.3 → letify-1.2.4}/letify/store/sendorder.py +0 -0
  114. {letify-1.2.3 → letify-1.2.4}/letify/store/volume.py +0 -0
  115. {letify-1.2.3 → letify-1.2.4}/letify/stubs.py +0 -0
  116. {letify-1.2.3 → letify-1.2.4}/letify/tools.py +0 -0
  117. {letify-1.2.3 → letify-1.2.4}/letify/transport/__init__.py +0 -0
  118. {letify-1.2.3 → letify-1.2.4}/letify/transport/agent.py +0 -0
  119. {letify-1.2.3 → letify-1.2.4}/letify/transport/announce.py +0 -0
  120. {letify-1.2.3 → letify-1.2.4}/letify/transport/link.py +0 -0
  121. {letify-1.2.3 → letify-1.2.4}/letify/transport/nat.py +0 -0
  122. {letify-1.2.3 → letify-1.2.4}/letify/transport/probe.py +0 -0
  123. {letify-1.2.3 → letify-1.2.4}/letify/transport/rendezvous.py +0 -0
  124. {letify-1.2.3 → letify-1.2.4}/letify/transport/setup.py +0 -0
  125. {letify-1.2.3 → letify-1.2.4}/letify/transport/sshopts.py +0 -0
  126. {letify-1.2.3 → letify-1.2.4}/letify/transport/strategies.py +0 -0
  127. {letify-1.2.3 → letify-1.2.4}/letify-core/.gitignore +0 -0
  128. {letify-1.2.3 → letify-1.2.4}/letify-core/build.py +0 -0
  129. {letify-1.2.3 → letify-1.2.4}/letify-core/letify-agent/Cargo.toml +0 -0
  130. {letify-1.2.3 → letify-1.2.4}/letify-core/letify-agent/src/driver.rs +0 -0
  131. {letify-1.2.3 → letify-1.2.4}/letify-core/letify-agent/src/main.rs +0 -0
  132. {letify-1.2.3 → letify-1.2.4}/letify-core/letify-driver/Cargo.toml +0 -0
  133. {letify-1.2.3 → letify-1.2.4}/letify-core/letify-driver/src/client.rs +0 -0
  134. {letify-1.2.3 → letify-1.2.4}/letify-core/letify-driver/src/exports.rs +0 -0
  135. {letify-1.2.3 → letify-1.2.4}/letify-core/letify-driver/src/image.rs +0 -0
  136. {letify-1.2.3 → letify-1.2.4}/letify-core/letify-driver/src/lib.rs +0 -0
  137. {letify-1.2.3 → letify-1.2.4}/letify-core/letify-driver/src/status.rs +0 -0
  138. {letify-1.2.3 → letify-1.2.4}/letify-core/letify-driver/src/table.rs +0 -0
  139. {letify-1.2.3 → letify-1.2.4}/letify-core/letify-wire/Cargo.toml +0 -0
  140. {letify-1.2.3 → letify-1.2.4}/letify-core/letify-wire/src/lib.rs +0 -0
  141. {letify-1.2.3 → letify-1.2.4}/letify-core/letify-wire/tests/throughput.rs +0 -0
  142. {letify-1.2.3 → letify-1.2.4}/letify-core/rust-toolchain.toml +0 -0
  143. {letify-1.2.3 → letify-1.2.4}/letify-ext/.vscodeignore +0 -0
  144. {letify-1.2.3 → letify-1.2.4}/letify-ext/LICENSE +0 -0
  145. {letify-1.2.3 → letify-1.2.4}/letify-ext/README.md +0 -0
  146. {letify-1.2.3 → letify-1.2.4}/letify-ext/media/icon.svg +0 -0
  147. {letify-1.2.3 → letify-1.2.4}/letify-ext/src/cli.ts +0 -0
  148. {letify-1.2.3 → letify-1.2.4}/letify-ext/src/extension.ts +0 -0
  149. {letify-1.2.3 → letify-1.2.4}/letify-ext/src/history.ts +0 -0
  150. {letify-1.2.3 → letify-1.2.4}/letify-ext/src/model.ts +0 -0
  151. {letify-1.2.3 → letify-1.2.4}/letify-ext/src/poll.ts +0 -0
  152. {letify-1.2.3 → letify-1.2.4}/letify-ext/src/render.ts +0 -0
  153. {letify-1.2.3 → letify-1.2.4}/letify-ext/test/cli.test.ts +0 -0
  154. {letify-1.2.3 → letify-1.2.4}/letify-ext/test/manifest.test.ts +0 -0
  155. {letify-1.2.3 → letify-1.2.4}/letify-ext/test/model.test.ts +0 -0
  156. {letify-1.2.3 → letify-1.2.4}/letify-ext/test/regressions.test.ts +0 -0
  157. {letify-1.2.3 → letify-1.2.4}/letify-ext/tsconfig.json +0 -0
  158. {letify-1.2.3 → letify-1.2.4}/letify_providers.py +0 -0
  159. {letify-1.2.3 → letify-1.2.4}/scripts/sync-main.sh +0 -0
  160. {letify-1.2.3 → letify-1.2.4}/scripts/version.py +0 -0
  161. {letify-1.2.3 → letify-1.2.4}/tests/conftest.py +0 -0
  162. {letify-1.2.3 → letify-1.2.4}/tests/fake_kaggle_adapter.py +0 -0
  163. {letify-1.2.3 → letify-1.2.4}/tests/fake_modal_adapter.py +0 -0
  164. {letify-1.2.3 → letify-1.2.4}/tests/test_cli.py +0 -0
  165. {letify-1.2.3 → letify-1.2.4}/tests/test_cli_output.py +0 -0
  166. {letify-1.2.3 → letify-1.2.4}/tests/test_colab_keepalive.py +0 -0
  167. {letify-1.2.3 → letify-1.2.4}/tests/test_config.py +0 -0
  168. {letify-1.2.3 → letify-1.2.4}/tests/test_core.py +0 -0
  169. {letify-1.2.3 → letify-1.2.4}/tests/test_data.py +0 -0
  170. {letify-1.2.3 → letify-1.2.4}/tests/test_data_streaming.py +0 -0
  171. {letify-1.2.3 → letify-1.2.4}/tests/test_declare.py +0 -0
  172. {letify-1.2.3 → letify-1.2.4}/tests/test_device.py +0 -0
  173. {letify-1.2.3 → letify-1.2.4}/tests/test_editor_sessions.py +0 -0
  174. {letify-1.2.3 → letify-1.2.4}/tests/test_elice.py +0 -0
  175. {letify-1.2.3 → letify-1.2.4}/tests/test_frames.py +0 -0
  176. {letify-1.2.3 → letify-1.2.4}/tests/test_install.py +0 -0
  177. {letify-1.2.3 → letify-1.2.4}/tests/test_kaggle.py +0 -0
  178. {letify-1.2.3 → letify-1.2.4}/tests/test_kaggle_cookie.py +0 -0
  179. {letify-1.2.3 → letify-1.2.4}/tests/test_notebook.py +0 -0
  180. {letify-1.2.3 → letify-1.2.4}/tests/test_paths.py +0 -0
  181. {letify-1.2.3 → letify-1.2.4}/tests/test_protocol.py +0 -0
  182. {letify-1.2.3 → letify-1.2.4}/tests/test_providers.py +0 -0
  183. {letify-1.2.3 → letify-1.2.4}/tests/test_remoting.py +0 -0
  184. {letify-1.2.3 → letify-1.2.4}/tests/test_render.py +0 -0
  185. {letify-1.2.3 → letify-1.2.4}/tests/test_runtime.py +0 -0
  186. {letify-1.2.3 → letify-1.2.4}/tests/test_ship.py +0 -0
  187. {letify-1.2.3 → letify-1.2.4}/tests/test_store.py +0 -0
  188. {letify-1.2.3 → letify-1.2.4}/tests/test_strategies.py +0 -0
  189. {letify-1.2.3 → letify-1.2.4}/tests/test_stubs.py +0 -0
  190. {letify-1.2.3 → letify-1.2.4}/tests/test_transport.py +0 -0
  191. {letify-1.2.3 → letify-1.2.4}/tests/test_tunnel_setup.py +0 -0
  192. {letify-1.2.3 → letify-1.2.4}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 1.2.3
3
+ Version: 1.2.4
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.3"
65
+ __version__ = "1.2.4"
66
66
 
67
67
 
68
68
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -40,6 +40,15 @@ DEFAULT_MIN_MIB_PER_S = 10.0
40
40
  DEFAULT_MAX_RTT_MS = 300.0
41
41
 
42
42
 
43
+ class _BelowFloor(ProviderUnavailable):
44
+ """Every connected strategy measured below the floor.
45
+
46
+ Spec "Choosing a link": this does not end the race, because a strategy still
47
+ connecting may clear the floor. The race raises it only once nothing is left to wait
48
+ for, and it reaches the caller as the ``ProviderUnavailable`` it is.
49
+ """
50
+
51
+
43
52
  @dataclass(frozen=True)
44
53
  class LinkFloor:
45
54
  """The slowest link an account accepts. A probe below this fails the connection."""
@@ -402,43 +411,86 @@ class Pipeline:
402
411
 
403
412
  probed = {i for i, s in enumerate(applicable) if getattr(s, "probed", True)}
404
413
  deadline = time.monotonic() + self.timeout
414
+ held = {s.name for s in applicable if not getattr(s, "probed", True)}
415
+ #: Strategies already offered to _choose, so a later round considers only new ones.
416
+ seen: set[int] = set()
417
+ rejected: _BelowFloor | None = None
418
+
419
+ # Spec "Choosing a link": the race is not over until a link is accepted. A round
420
+ # that ends with every connected strategy below the floor drops the grace period
421
+ # and waits out the rest of the timeout for the strategies still connecting.
422
+ while True:
423
+ with condition:
424
+ while len(outcomes) < len(applicable):
425
+ fresh = [
426
+ index
427
+ for index, outcome in outcomes.items()
428
+ if index not in seen and not isinstance(outcome, Exception)
429
+ ]
430
+ if state["first"] is None and probed <= outcomes.keys() and fresh:
431
+ # Every probed strategy failed, so an unprobed one is all that is left.
432
+ break
433
+ now = time.monotonic()
434
+ limit = deadline if state["first"] is None else state["first"] + self.grace
435
+ if now >= limit:
436
+ break
437
+ condition.wait(limit - now)
438
+ settled = dict(outcomes)
439
+
440
+ connected = []
441
+ for index, strategy in enumerate(applicable):
442
+ if index in seen or index not in settled:
443
+ continue
444
+ seen.add(index)
445
+ outcome = settled[index]
446
+ if isinstance(outcome, Exception):
447
+ reasons.append(f"{strategy.name}: {outcome}")
448
+ else:
449
+ connected.append(outcome)
450
+
451
+ if connected:
452
+ connected.sort(key=lambda link: link.rank)
453
+ try:
454
+ chosen, measured = self._choose(connected, reasons, held)
455
+ except _BelowFloor as below:
456
+ rejected = below
457
+ else:
458
+ with condition:
459
+ state["decided"] = True
460
+ for index, event in enumerate(cancels):
461
+ if index not in seen:
462
+ event.set()
463
+ self._report_unsettled(applicable, seen, reasons)
464
+ first = state["first_name"]
465
+ if first is not None and first != chosen.strategy:
466
+ self._say(f"switching from {first} to {chosen.strategy}")
467
+ return chosen, measured
468
+
469
+ if len(seen) == len(applicable) or time.monotonic() >= deadline:
470
+ break
471
+ # Nothing acceptable yet, so the strategies still connecting get the rest of
472
+ # the timeout rather than the grace period that a rejected link started.
473
+ with condition:
474
+ state["first"] = None
475
+ state["first_name"] = None
476
+
405
477
  with condition:
406
- while len(outcomes) < len(applicable):
407
- connected = [i for i, o in outcomes.items() if not isinstance(o, Exception)]
408
- if state["first"] is None and probed <= outcomes.keys() and connected:
409
- # Every probed strategy failed, so an unprobed one is all that is left.
410
- break
411
- now = time.monotonic()
412
- limit = deadline if state["first"] is None else state["first"] + self.grace
413
- if now >= limit:
414
- break
415
- condition.wait(limit - now)
416
478
  state["decided"] = True
417
- settled = dict(outcomes)
418
479
  for index, event in enumerate(cancels):
419
- if index not in settled:
480
+ if index not in seen:
420
481
  event.set()
421
-
422
- connected = []
482
+ self._report_unsettled(applicable, seen, reasons)
483
+ if rejected is not None:
484
+ raise rejected
485
+ self._say("no strategy connected")
486
+ raise ProviderUnavailable("shell", self._explain(reasons))
487
+
488
+ def _report_unsettled(self, applicable: list[Any], seen: set[int], reasons: list[str]) -> None:
489
+ """Name every strategy that had not connected when the choice was made."""
423
490
  for index, strategy in enumerate(applicable):
424
- outcome = settled.get(index)
425
- if outcome is None:
491
+ if index not in seen:
426
492
  reasons.append(f"{strategy.name}: did not connect in time")
427
493
  self._say(f"{strategy.name} timed out: not connected when the choice was made")
428
- elif isinstance(outcome, Exception):
429
- reasons.append(f"{strategy.name}: {outcome}")
430
- else:
431
- connected.append(outcome)
432
- if not connected:
433
- self._say("no strategy connected")
434
- raise ProviderUnavailable("shell", self._explain(reasons))
435
- connected.sort(key=lambda link: link.rank)
436
- held = {s.name for s in applicable if not getattr(s, "probed", True)}
437
- chosen, measured = self._choose(connected, reasons, held)
438
- first = state["first_name"]
439
- if first is not None and first != chosen.strategy:
440
- self._say(f"switching from {first} to {chosen.strategy}")
441
- return chosen, measured
442
494
 
443
495
  def _measure(self, link: Any) -> ProbeResult | None:
444
496
  """Probe a link and print the numbers. None when the link cannot carry the probe."""
@@ -470,7 +522,7 @@ class Pipeline:
470
522
  measured = None
471
523
  if measured is None and floored_out:
472
524
  _close(link)
473
- raise ProviderUnavailable("shell", self._explain(reasons))
525
+ raise _BelowFloor("shell", self._explain(reasons))
474
526
  if link.strategy in held:
475
527
  self._fall_back(link)
476
528
  else:
@@ -480,7 +532,8 @@ class Pipeline:
480
532
  below = f"{link.strategy}: below the floor: {', '.join(floored)}"
481
533
  self._say(f"rejected {below}")
482
534
  _close(link)
483
- raise ProviderUnavailable("shell", self._explain([*reasons, below]))
535
+ reasons.append(below)
536
+ raise _BelowFloor("shell", self._explain(reasons))
484
537
  self._say(f"chose {link.strategy}: only one connected")
485
538
  return link, measured
486
539
 
@@ -502,7 +555,9 @@ class Pipeline:
502
555
  floored = self.floor.violations(measured)
503
556
  if floored:
504
557
  self._say(f"rejected {link.strategy}: below the floor: {', '.join(floored)}")
505
- floored_out.append(f"{link.strategy}: below the floor: {', '.join(floored)}")
558
+ below = f"{link.strategy}: below the floor: {', '.join(floored)}"
559
+ floored_out.append(below)
560
+ reasons.append(below)
506
561
  _close(link)
507
562
  else:
508
563
  above_floor.append((link, measured))
@@ -534,8 +589,8 @@ class Pipeline:
534
589
  for link in unprobed:
535
590
  _close(link)
536
591
  self._say("every probe was below the floor")
537
- raise ProviderUnavailable(
538
- "shell", self._explain([*reasons, "every probe was below the floor", *floored_out])
592
+ raise _BelowFloor(
593
+ "shell", self._explain([*reasons, "every probe was below the floor"])
539
594
  )
540
595
  elif unprobed:
541
596
  chosen, measured = unprobed[0], None
@@ -10,7 +10,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
10
10
 
11
11
  [[package]]
12
12
  name = "letify-agent"
13
- version = "1.2.3"
13
+ version = "1.2.4"
14
14
  dependencies = [
15
15
  "letify-wire",
16
16
  "libloading",
@@ -18,14 +18,14 @@ dependencies = [
18
18
 
19
19
  [[package]]
20
20
  name = "letify-driver"
21
- version = "1.2.3"
21
+ version = "1.2.4"
22
22
  dependencies = [
23
23
  "letify-wire",
24
24
  ]
25
25
 
26
26
  [[package]]
27
27
  name = "letify-wire"
28
- version = "1.2.3"
28
+ version = "1.2.4"
29
29
 
30
30
  [[package]]
31
31
  name = "libloading"
@@ -8,7 +8,7 @@ resolver = "3"
8
8
  members = ["letify-wire", "letify-driver", "letify-agent"]
9
9
 
10
10
  [workspace.package]
11
- version = "1.2.3"
11
+ version = "1.2.4"
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": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "letify-status",
9
- "version": "1.2.3",
9
+ "version": "1.2.4",
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": "1.2.3",
5
+ "version": "1.2.4",
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 = "1.2.3"
3
+ version = "1.2.4"
4
4
  description = "Declarations that become infrastructure."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -6,9 +6,11 @@ Strategies and probes are the fakes from conftest, so delays and numbers are con
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import pytest
9
10
  from conftest import FakeProbe, FakeStrategy
10
11
 
11
12
  import letify
13
+ from letify.errors import ProviderUnavailable
12
14
  from letify.transport.pipeline import Fingerprint, LinkCache, LinkFloor, Pipeline
13
15
  from letify.transport.probe import ProbeResult
14
16
 
@@ -192,3 +194,32 @@ def test_a_quiet_launcher_prints_no_connection_lines(config_file, capsys) -> Non
192
194
  letify.Launcher(config_file(body), home=False, announce=False).provider("lab").connect()
193
195
  captured = capsys.readouterr()
194
196
  assert (captured.out, captured.err) == ("", "")
197
+
198
+
199
+ # -- Spec: Choosing a link, the race is not over until a link is accepted ----------
200
+
201
+
202
+ def test_a_strategy_rejected_by_the_floor_does_not_end_the_race() -> None:
203
+ # The reported Colab run: tcp_punch connected at 10.7 s and was rejected by the floor,
204
+ # and tailcat connected 2 s later and was closed unprobed, so the connection failed
205
+ # although tailcat was acceptable. Spec "Choosing a link".
206
+ floor = LinkFloor(min_bps=10 * MIB, max_rtt_ms=300.0)
207
+ punch = FakeStrategy("tcp_punch", 2, delay=0.05, result=result(8.1, 0.2, rtt=138.0))
208
+ tailcat = FakeStrategy("tailcat", 3, delay=0.45, result=result(24.0, 20.0))
209
+
210
+ chosen = pipeline([punch, tailcat], grace=0.2, floor=floor).connect()
211
+
212
+ assert chosen.strategy == "tailcat"
213
+
214
+
215
+ def test_the_connection_still_fails_when_the_later_strategy_is_also_below_the_floor() -> None:
216
+ # Spec "Choosing a link": waiting longer is not accepting less. Every strategy settles
217
+ # below the floor, so the connection fails and names both rejections.
218
+ floor = LinkFloor(min_bps=10 * MIB, max_rtt_ms=300.0)
219
+ punch = FakeStrategy("tcp_punch", 2, delay=0.05, result=result(8.1, 0.2, rtt=138.0))
220
+ tailcat = FakeStrategy("tailcat", 3, delay=0.45, result=result(4.0, 0.1))
221
+
222
+ with pytest.raises(ProviderUnavailable) as raised:
223
+ pipeline([punch, tailcat], grace=0.2, floor=floor).connect()
224
+
225
+ assert "tcp_punch" in str(raised.value) and "tailcat" in str(raised.value)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
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