letify 1.2.2__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.2 → letify-1.2.4}/PKG-INFO +1 -1
  2. {letify-1.2.2 → letify-1.2.4}/letify/__init__.py +1 -1
  3. {letify-1.2.2 → letify-1.2.4}/letify/store/volume.py +6 -2
  4. {letify-1.2.2 → letify-1.2.4}/letify/transport/nat.py +42 -10
  5. {letify-1.2.2 → letify-1.2.4}/letify/transport/pipeline.py +90 -35
  6. {letify-1.2.2 → letify-1.2.4}/letify-core/Cargo.lock +3 -3
  7. {letify-1.2.2 → letify-1.2.4}/letify-core/Cargo.toml +1 -1
  8. {letify-1.2.2 → letify-1.2.4}/letify-ext/package-lock.json +2 -2
  9. {letify-1.2.2 → letify-1.2.4}/letify-ext/package.json +1 -1
  10. {letify-1.2.2 → letify-1.2.4}/pyproject.toml +1 -1
  11. {letify-1.2.2 → letify-1.2.4}/tests/conftest.py +7 -0
  12. {letify-1.2.2 → letify-1.2.4}/tests/test_link_log.py +31 -0
  13. {letify-1.2.2 → letify-1.2.4}/tests/test_paths.py +35 -0
  14. {letify-1.2.2 → letify-1.2.4}/tests/test_strategies.py +37 -0
  15. {letify-1.2.2 → letify-1.2.4}/.github/workflows/ci.yml +0 -0
  16. {letify-1.2.2 → letify-1.2.4}/.github/workflows/docs.yml +0 -0
  17. {letify-1.2.2 → letify-1.2.4}/.github/workflows/publish.yml +0 -0
  18. {letify-1.2.2 → letify-1.2.4}/.gitignore +0 -0
  19. {letify-1.2.2 → letify-1.2.4}/.researchtree +0 -0
  20. {letify-1.2.2 → letify-1.2.4}/LICENSE +0 -0
  21. {letify-1.2.2 → letify-1.2.4}/README.md +0 -0
  22. {letify-1.2.2 → letify-1.2.4}/docs/guide/01-getting-started.md +0 -0
  23. {letify-1.2.2 → letify-1.2.4}/docs/guide/02-providers.md +0 -0
  24. {letify-1.2.2 → letify-1.2.4}/docs/guide/03-execution-modes.md +0 -0
  25. {letify-1.2.2 → letify-1.2.4}/docs/guide/04-environments-and-data.md +0 -0
  26. {letify-1.2.2 → letify-1.2.4}/docs/guide/05-concurrency.md +0 -0
  27. {letify-1.2.2 → letify-1.2.4}/docs/guide/06-cost.md +0 -0
  28. {letify-1.2.2 → letify-1.2.4}/docs/guide/07-troubleshooting.md +0 -0
  29. {letify-1.2.2 → letify-1.2.4}/docs/guide/08-releasing.md +0 -0
  30. {letify-1.2.2 → letify-1.2.4}/docs/guide/README.md +0 -0
  31. {letify-1.2.2 → letify-1.2.4}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  32. {letify-1.2.2 → 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
  33. {letify-1.2.2 → letify-1.2.4}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  34. {letify-1.2.2 → 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
  35. {letify-1.2.2 → letify-1.2.4}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  36. {letify-1.2.2 → letify-1.2.4}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  37. {letify-1.2.2 → letify-1.2.4}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  38. {letify-1.2.2 → letify-1.2.4}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  39. {letify-1.2.2 → letify-1.2.4}/docs/guide/ko/README.md +0 -0
  40. {letify-1.2.2 → letify-1.2.4}/docs/locales/README_ko.md +0 -0
  41. {letify-1.2.2 → letify-1.2.4}/docs/mkdocs.yml +0 -0
  42. {letify-1.2.2 → letify-1.2.4}/examples/README.md +0 -0
  43. {letify-1.2.2 → letify-1.2.4}/examples/nvfp4-lora/.letify.example +0 -0
  44. {letify-1.2.2 → letify-1.2.4}/examples/nvfp4-lora/00_smoke.py +0 -0
  45. {letify-1.2.2 → letify-1.2.4}/examples/nvfp4-lora/01_sweep.py +0 -0
  46. {letify-1.2.2 → letify-1.2.4}/examples/nvfp4-lora/02_resume.py +0 -0
  47. {letify-1.2.2 → letify-1.2.4}/examples/nvfp4-lora/03_watch.py +0 -0
  48. {letify-1.2.2 → letify-1.2.4}/examples/nvfp4-lora/README.md +0 -0
  49. {letify-1.2.2 → letify-1.2.4}/examples/nvfp4-lora/README_ko.md +0 -0
  50. {letify-1.2.2 → letify-1.2.4}/examples/nvfp4-lora/common.py +0 -0
  51. {letify-1.2.2 → letify-1.2.4}/examples/nvfp4-lora/recipe.py +0 -0
  52. {letify-1.2.2 → letify-1.2.4}/letify/cli.py +0 -0
  53. {letify-1.2.2 → letify-1.2.4}/letify/config/__init__.py +0 -0
  54. {letify-1.2.2 → letify-1.2.4}/letify/config/inventory.py +0 -0
  55. {letify-1.2.2 → letify-1.2.4}/letify/config/login.py +0 -0
  56. {letify-1.2.2 → letify-1.2.4}/letify/config/schema.py +0 -0
  57. {letify-1.2.2 → letify-1.2.4}/letify/config/secrets.py +0 -0
  58. {letify-1.2.2 → letify-1.2.4}/letify/config/writer.py +0 -0
  59. {letify-1.2.2 → letify-1.2.4}/letify/declare/__init__.py +0 -0
  60. {letify-1.2.2 → letify-1.2.4}/letify/declare/cache.py +0 -0
  61. {letify-1.2.2 → letify-1.2.4}/letify/declare/env.py +0 -0
  62. {letify-1.2.2 → letify-1.2.4}/letify/declare/function.py +0 -0
  63. {letify-1.2.2 → letify-1.2.4}/letify/declare/instance.py +0 -0
  64. {letify-1.2.2 → letify-1.2.4}/letify/errors.py +0 -0
  65. {letify-1.2.2 → letify-1.2.4}/letify/install.py +0 -0
  66. {letify-1.2.2 → letify-1.2.4}/letify/launcher.py +0 -0
  67. {letify-1.2.2 → letify-1.2.4}/letify/paths.py +0 -0
  68. {letify-1.2.2 → letify-1.2.4}/letify/protocol/__init__.py +0 -0
  69. {letify-1.2.2 → letify-1.2.4}/letify/protocol/codec.py +0 -0
  70. {letify-1.2.2 → letify-1.2.4}/letify/protocol/driver.py +0 -0
  71. {letify-1.2.2 → letify-1.2.4}/letify/protocol/handle.py +0 -0
  72. {letify-1.2.2 → letify-1.2.4}/letify/protocol/wire.py +0 -0
  73. {letify-1.2.2 → letify-1.2.4}/letify/protocol/worker.py +0 -0
  74. {letify-1.2.2 → letify-1.2.4}/letify/providers/__init__.py +0 -0
  75. {letify-1.2.2 → letify-1.2.4}/letify/providers/base.py +0 -0
  76. {letify-1.2.2 → letify-1.2.4}/letify/providers/colab.py +0 -0
  77. {letify-1.2.2 → letify-1.2.4}/letify/providers/colab_files.py +0 -0
  78. {letify-1.2.2 → letify-1.2.4}/letify/providers/colab_keepalive.py +0 -0
  79. {letify-1.2.2 → letify-1.2.4}/letify/providers/elice.py +0 -0
  80. {letify-1.2.2 → letify-1.2.4}/letify/providers/kaggle.py +0 -0
  81. {letify-1.2.2 → letify-1.2.4}/letify/providers/kaggle_adapter.py +0 -0
  82. {letify-1.2.2 → letify-1.2.4}/letify/providers/local.py +0 -0
  83. {letify-1.2.2 → letify-1.2.4}/letify/providers/modal.py +0 -0
  84. {letify-1.2.2 → letify-1.2.4}/letify/providers/modal_adapter.py +0 -0
  85. {letify-1.2.2 → letify-1.2.4}/letify/providers/naming.py +0 -0
  86. {letify-1.2.2 → letify-1.2.4}/letify/providers/shell.py +0 -0
  87. {letify-1.2.2 → letify-1.2.4}/letify/providers/tunnel.py +0 -0
  88. {letify-1.2.2 → letify-1.2.4}/letify/providers/usage.py +0 -0
  89. {letify-1.2.2 → letify-1.2.4}/letify/remoting/__init__.py +0 -0
  90. {letify-1.2.2 → letify-1.2.4}/letify/remoting/capability.py +0 -0
  91. {letify-1.2.2 → letify-1.2.4}/letify/remoting/device/__init__.py +0 -0
  92. {letify-1.2.2 → letify-1.2.4}/letify/remoting/device/client.py +0 -0
  93. {letify-1.2.2 → letify-1.2.4}/letify/remoting/device/cuda.py +0 -0
  94. {letify-1.2.2 → letify-1.2.4}/letify/remoting/device/executor.py +0 -0
  95. {letify-1.2.2 → letify-1.2.4}/letify/remoting/device/frames.py +0 -0
  96. {letify-1.2.2 → letify-1.2.4}/letify/remoting/device/guard.py +0 -0
  97. {letify-1.2.2 → letify-1.2.4}/letify/remoting/device/tensor.py +0 -0
  98. {letify-1.2.2 → letify-1.2.4}/letify/remoting/device/trace.py +0 -0
  99. {letify-1.2.2 → letify-1.2.4}/letify/remoting/device/value.py +0 -0
  100. {letify-1.2.2 → letify-1.2.4}/letify/remoting/loader.py +0 -0
  101. {letify-1.2.2 → letify-1.2.4}/letify/remoting/probe.py +0 -0
  102. {letify-1.2.2 → letify-1.2.4}/letify/render.py +0 -0
  103. {letify-1.2.2 → letify-1.2.4}/letify/runtime/__init__.py +0 -0
  104. {letify-1.2.2 → letify-1.2.4}/letify/runtime/bootstrap.py +0 -0
  105. {letify-1.2.2 → letify-1.2.4}/letify/runtime/channel.py +0 -0
  106. {letify-1.2.2 → letify-1.2.4}/letify/runtime/lease.py +0 -0
  107. {letify-1.2.2 → letify-1.2.4}/letify/runtime/pool.py +0 -0
  108. {letify-1.2.2 → letify-1.2.4}/letify/runtime/session.py +0 -0
  109. {letify-1.2.2 → letify-1.2.4}/letify/runtime/telemetry.py +0 -0
  110. {letify-1.2.2 → letify-1.2.4}/letify/store/__init__.py +0 -0
  111. {letify-1.2.2 → letify-1.2.4}/letify/store/backends/__init__.py +0 -0
  112. {letify-1.2.2 → letify-1.2.4}/letify/store/backends/filesystem.py +0 -0
  113. {letify-1.2.2 → letify-1.2.4}/letify/store/backends/google_auth.py +0 -0
  114. {letify-1.2.2 → letify-1.2.4}/letify/store/backends/layout.py +0 -0
  115. {letify-1.2.2 → letify-1.2.4}/letify/store/backends/objects.py +0 -0
  116. {letify-1.2.2 → letify-1.2.4}/letify/store/cas.py +0 -0
  117. {letify-1.2.2 → letify-1.2.4}/letify/store/pathdata.py +0 -0
  118. {letify-1.2.2 → letify-1.2.4}/letify/store/sendorder.py +0 -0
  119. {letify-1.2.2 → letify-1.2.4}/letify/stubs.py +0 -0
  120. {letify-1.2.2 → letify-1.2.4}/letify/tools.py +0 -0
  121. {letify-1.2.2 → letify-1.2.4}/letify/transport/__init__.py +0 -0
  122. {letify-1.2.2 → letify-1.2.4}/letify/transport/agent.py +0 -0
  123. {letify-1.2.2 → letify-1.2.4}/letify/transport/announce.py +0 -0
  124. {letify-1.2.2 → letify-1.2.4}/letify/transport/link.py +0 -0
  125. {letify-1.2.2 → letify-1.2.4}/letify/transport/probe.py +0 -0
  126. {letify-1.2.2 → letify-1.2.4}/letify/transport/rendezvous.py +0 -0
  127. {letify-1.2.2 → letify-1.2.4}/letify/transport/setup.py +0 -0
  128. {letify-1.2.2 → letify-1.2.4}/letify/transport/sshopts.py +0 -0
  129. {letify-1.2.2 → letify-1.2.4}/letify/transport/strategies.py +0 -0
  130. {letify-1.2.2 → letify-1.2.4}/letify-core/.gitignore +0 -0
  131. {letify-1.2.2 → letify-1.2.4}/letify-core/build.py +0 -0
  132. {letify-1.2.2 → letify-1.2.4}/letify-core/letify-agent/Cargo.toml +0 -0
  133. {letify-1.2.2 → letify-1.2.4}/letify-core/letify-agent/src/driver.rs +0 -0
  134. {letify-1.2.2 → letify-1.2.4}/letify-core/letify-agent/src/main.rs +0 -0
  135. {letify-1.2.2 → letify-1.2.4}/letify-core/letify-driver/Cargo.toml +0 -0
  136. {letify-1.2.2 → letify-1.2.4}/letify-core/letify-driver/src/client.rs +0 -0
  137. {letify-1.2.2 → letify-1.2.4}/letify-core/letify-driver/src/exports.rs +0 -0
  138. {letify-1.2.2 → letify-1.2.4}/letify-core/letify-driver/src/image.rs +0 -0
  139. {letify-1.2.2 → letify-1.2.4}/letify-core/letify-driver/src/lib.rs +0 -0
  140. {letify-1.2.2 → letify-1.2.4}/letify-core/letify-driver/src/status.rs +0 -0
  141. {letify-1.2.2 → letify-1.2.4}/letify-core/letify-driver/src/table.rs +0 -0
  142. {letify-1.2.2 → letify-1.2.4}/letify-core/letify-wire/Cargo.toml +0 -0
  143. {letify-1.2.2 → letify-1.2.4}/letify-core/letify-wire/src/lib.rs +0 -0
  144. {letify-1.2.2 → letify-1.2.4}/letify-core/letify-wire/tests/throughput.rs +0 -0
  145. {letify-1.2.2 → letify-1.2.4}/letify-core/rust-toolchain.toml +0 -0
  146. {letify-1.2.2 → letify-1.2.4}/letify-ext/.vscodeignore +0 -0
  147. {letify-1.2.2 → letify-1.2.4}/letify-ext/LICENSE +0 -0
  148. {letify-1.2.2 → letify-1.2.4}/letify-ext/README.md +0 -0
  149. {letify-1.2.2 → letify-1.2.4}/letify-ext/media/icon.svg +0 -0
  150. {letify-1.2.2 → letify-1.2.4}/letify-ext/src/cli.ts +0 -0
  151. {letify-1.2.2 → letify-1.2.4}/letify-ext/src/extension.ts +0 -0
  152. {letify-1.2.2 → letify-1.2.4}/letify-ext/src/history.ts +0 -0
  153. {letify-1.2.2 → letify-1.2.4}/letify-ext/src/model.ts +0 -0
  154. {letify-1.2.2 → letify-1.2.4}/letify-ext/src/poll.ts +0 -0
  155. {letify-1.2.2 → letify-1.2.4}/letify-ext/src/render.ts +0 -0
  156. {letify-1.2.2 → letify-1.2.4}/letify-ext/test/cli.test.ts +0 -0
  157. {letify-1.2.2 → letify-1.2.4}/letify-ext/test/manifest.test.ts +0 -0
  158. {letify-1.2.2 → letify-1.2.4}/letify-ext/test/model.test.ts +0 -0
  159. {letify-1.2.2 → letify-1.2.4}/letify-ext/test/regressions.test.ts +0 -0
  160. {letify-1.2.2 → letify-1.2.4}/letify-ext/tsconfig.json +0 -0
  161. {letify-1.2.2 → letify-1.2.4}/letify_providers.py +0 -0
  162. {letify-1.2.2 → letify-1.2.4}/scripts/sync-main.sh +0 -0
  163. {letify-1.2.2 → letify-1.2.4}/scripts/version.py +0 -0
  164. {letify-1.2.2 → letify-1.2.4}/tests/fake_kaggle_adapter.py +0 -0
  165. {letify-1.2.2 → letify-1.2.4}/tests/fake_modal_adapter.py +0 -0
  166. {letify-1.2.2 → letify-1.2.4}/tests/test_cli.py +0 -0
  167. {letify-1.2.2 → letify-1.2.4}/tests/test_cli_output.py +0 -0
  168. {letify-1.2.2 → letify-1.2.4}/tests/test_colab_keepalive.py +0 -0
  169. {letify-1.2.2 → letify-1.2.4}/tests/test_config.py +0 -0
  170. {letify-1.2.2 → letify-1.2.4}/tests/test_core.py +0 -0
  171. {letify-1.2.2 → letify-1.2.4}/tests/test_data.py +0 -0
  172. {letify-1.2.2 → letify-1.2.4}/tests/test_data_streaming.py +0 -0
  173. {letify-1.2.2 → letify-1.2.4}/tests/test_declare.py +0 -0
  174. {letify-1.2.2 → letify-1.2.4}/tests/test_device.py +0 -0
  175. {letify-1.2.2 → letify-1.2.4}/tests/test_editor_sessions.py +0 -0
  176. {letify-1.2.2 → letify-1.2.4}/tests/test_elice.py +0 -0
  177. {letify-1.2.2 → letify-1.2.4}/tests/test_frames.py +0 -0
  178. {letify-1.2.2 → letify-1.2.4}/tests/test_install.py +0 -0
  179. {letify-1.2.2 → letify-1.2.4}/tests/test_kaggle.py +0 -0
  180. {letify-1.2.2 → letify-1.2.4}/tests/test_kaggle_cookie.py +0 -0
  181. {letify-1.2.2 → letify-1.2.4}/tests/test_notebook.py +0 -0
  182. {letify-1.2.2 → letify-1.2.4}/tests/test_protocol.py +0 -0
  183. {letify-1.2.2 → letify-1.2.4}/tests/test_providers.py +0 -0
  184. {letify-1.2.2 → letify-1.2.4}/tests/test_remoting.py +0 -0
  185. {letify-1.2.2 → letify-1.2.4}/tests/test_render.py +0 -0
  186. {letify-1.2.2 → letify-1.2.4}/tests/test_runtime.py +0 -0
  187. {letify-1.2.2 → letify-1.2.4}/tests/test_ship.py +0 -0
  188. {letify-1.2.2 → letify-1.2.4}/tests/test_store.py +0 -0
  189. {letify-1.2.2 → letify-1.2.4}/tests/test_stubs.py +0 -0
  190. {letify-1.2.2 → letify-1.2.4}/tests/test_transport.py +0 -0
  191. {letify-1.2.2 → letify-1.2.4}/tests/test_tunnel_setup.py +0 -0
  192. {letify-1.2.2 → 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.2
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.2"
65
+ __version__ = "1.2.4"
66
66
 
67
67
 
68
68
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -89,14 +89,18 @@ def environment_volume(provider: Provider) -> Volume:
89
89
  """The automatic environment store when an ephemeral declaration names no volumes.
90
90
 
91
91
  Spec "Materializing into a runtime": reuse the account's data bucket, or keep the
92
- archive on the client without requiring a bucket or a volume declaration.
92
+ archive on the client without requiring a bucket or a volume declaration. The client
93
+ store is the declared ``~/.letify/cache/storage/<name>`` root with the name
94
+ ``environments``, so it stays under the single local root while still reading archives
95
+ an earlier install left under ``~/.cache/letify/environments``.
93
96
  """
97
+ from ..paths import storage_cache_directory
94
98
  from .backends.filesystem import FilesystemBackend
95
99
  from .pathdata import data_bucket
96
100
 
97
101
  backend = data_bucket(provider)
98
102
  if backend is None:
99
- root = Path.home() / ".cache" / "letify" / "environments" / provider.kind / provider.alias
103
+ root = storage_cache_directory("environments") / provider.kind / provider.alias
100
104
  backend = FilesystemBackend(root)
101
105
  return Volume(provider, "environments", _store=Store(backend))
102
106
 
@@ -382,11 +382,10 @@ def begin(request: dict):
382
382
  stderr=subprocess.STDOUT,
383
383
  text=True,
384
384
  )
385
- for line in process.stdout:
386
- found = re.search(r"address:\s*(tc\S+)", line)
387
- if found:
388
- return {"address": found.group(1)}, process.wait
389
- raise RuntimeError("tailcat serve exited without printing its address")
385
+ address = _match_then_drain(process, process.stdout, r"address:\s*(tc\S+)")
386
+ if address is None:
387
+ raise RuntimeError("tailcat serve exited without printing its address")
388
+ return {"address": address}, process.wait
390
389
  if kind == "reverse_ssh":
391
390
  # The directory is under the account's workspace root, which may start with ~.
392
391
  key = Path(os.path.expanduser(request["key_directory"])) / "letify_reverse"
@@ -405,14 +404,47 @@ def begin(request: dict):
405
404
  stderr=subprocess.PIPE,
406
405
  text=True,
407
406
  ) # fmt: skip
408
- for line in process.stderr:
409
- found = re.search(r"Allocated port (\d+)", line)
410
- if found:
411
- return {"port": int(found.group(1))}, process.wait
412
- raise RuntimeError("ssh -R exited without allocating a port")
407
+ allocated = _match_then_drain(process, process.stderr, r"Allocated port (\d+)")
408
+ if allocated is None:
409
+ raise RuntimeError("ssh -R exited without allocating a port")
410
+ return {"port": int(allocated)}, process.wait
413
411
  raise ValueError(f"unknown rendezvous request {kind!r}")
414
412
 
415
413
 
414
+ def _match_then_drain(process, stream, pattern: str) -> str | None:
415
+ """First capture of ``pattern`` in ``stream``, then keep draining it in the background.
416
+
417
+ A helper letify starts and reads one line from keeps writing afterwards. Its pipe holds
418
+ about 64 KiB, so once nobody reads it the helper blocks on its next write and stops
419
+ moving bytes. ``tailcat serve`` carries the SSH connection itself, so that stall shows
420
+ up as a link that connects and then measures far below the link underneath it. Reading
421
+ on and discarding keeps the writer running, and keeps ``process.wait`` from deadlocking
422
+ against a full pipe.
423
+
424
+ Returns None when the stream ends before the pattern appears.
425
+ """
426
+ expression = re.compile(pattern)
427
+ found = None
428
+ for line in stream:
429
+ match = expression.search(line)
430
+ if match:
431
+ found = match.group(1)
432
+ break
433
+ if found is None:
434
+ return None
435
+
436
+ def drain() -> None:
437
+ try:
438
+ for _ in stream:
439
+ pass
440
+ except (OSError, ValueError):
441
+ # The process ended and the stream was closed under us, which is the normal end.
442
+ pass
443
+
444
+ threading.Thread(target=drain, name="letify-drain", daemon=True).start()
445
+ return found
446
+
447
+
416
448
  def _authorize(public_key: str) -> None:
417
449
  path = Path.home() / ".ssh" / "authorized_keys"
418
450
  path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
@@ -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.2"
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.2"
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.2"
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.2"
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.2",
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.2",
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.2",
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.2"
3
+ version = "1.2.4"
4
4
  description = "Declarations that become infrastructure."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -158,6 +158,13 @@ def uv_project(tmp_path: Path, monkeypatch) -> Path:
158
158
  subprocess.run([uv, "lock"], cwd=project, capture_output=True, check=True)
159
159
  monkeypatch.chdir(project)
160
160
  monkeypatch.setattr(bootstrap, "DEFAULT_WORKSPACE_ROOT", str(tmp_path / "runtime-workspace"))
161
+ # The automatic environment archive store lives under the user's own ~/.letify, so
162
+ # without this an archive a previous run published is found and a test that expects a
163
+ # sync sees "archive" instead. Spec "Materializing into a runtime".
164
+ from letify import paths
165
+
166
+ store = tmp_path / "storage"
167
+ monkeypatch.setattr(paths, "storage_cache_directory", lambda name: store / name)
161
168
  return project
162
169
 
163
170
 
@@ -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)
@@ -302,3 +302,38 @@ def test_compatibility_fallbacks_for_migrated_paths(
302
302
  Colab(ProviderConfig("colab_compat", "colab", {}, 0))
303
303
  # It should find the token via fallback
304
304
  assert (acct / ".config" / "colab-cli" / "token.json").is_file()
305
+
306
+
307
+ # -- Spec: Local machine, the filesystem storage backend root ----------------------
308
+
309
+
310
+ def test_the_automatic_environment_store_sits_under_the_declared_cache(monkeypatch, tmp_path):
311
+ # Spec "Materializing into a runtime": the automatic archive store is the
312
+ # ~/.letify/cache/storage/<name> row with the name "environments", not a directory of
313
+ # its own under ~/.cache.
314
+ from letify.config.schema import ProviderConfig
315
+ from letify.providers.local import Local
316
+ from letify.store.volume import environment_volume
317
+
318
+ monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path))
319
+ provider = Local(ProviderConfig("lab", "local", {}, 0))
320
+ volume = environment_volume(provider)
321
+ root = volume._store.backend.root
322
+
323
+ assert root == tmp_path / ".letify" / "cache" / "storage" / "environments" / "local" / "lab"
324
+ assert ".cache" not in root.parts
325
+
326
+
327
+ def test_an_existing_environment_store_outside_the_root_is_still_read(monkeypatch, tmp_path):
328
+ # Spec "Materializing into a runtime": archives an earlier install left under
329
+ # ~/.cache/letify/environments are read rather than abandoned.
330
+ from letify.config.schema import ProviderConfig
331
+ from letify.providers.local import Local
332
+ from letify.store.volume import environment_volume
333
+
334
+ legacy = tmp_path / ".cache" / "letify" / "environments"
335
+ legacy.mkdir(parents=True)
336
+ monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path))
337
+ provider = Local(ProviderConfig("lab", "local", {}, 0))
338
+
339
+ assert environment_volume(provider)._store.backend.root == legacy / "local" / "lab"
@@ -592,3 +592,40 @@ def test_the_remote_half_starts_no_sshd_when_an_ssh_server_already_answers(
592
592
  finally:
593
593
  server.close()
594
594
  assert not any(str(binary) in command for command in recorder.commands)
595
+
596
+
597
+ def _flooding_binary(directory, address: str, bytes_after: int) -> str:
598
+ """An executable that prints ``address`` the way tailcat does, then floods its output."""
599
+ import os
600
+ import sys
601
+ from pathlib import Path
602
+
603
+ path = Path(directory) / "flooding_tailcat"
604
+ path.write_text(
605
+ f"#!{sys.executable}\n"
606
+ "import sys\n"
607
+ f"print('Server listening with new address: {address}', flush=True)\n"
608
+ f"sys.stdout.write('x' * {bytes_after})\n"
609
+ "sys.stdout.flush()\n"
610
+ )
611
+ path.chmod(0o755)
612
+ return os.fspath(path)
613
+
614
+
615
+ def test_the_remote_half_keeps_reading_what_tailcat_serve_prints_after_the_address(
616
+ tmp_path,
617
+ ) -> None:
618
+ # A pipe holds about 64 KiB, so a megabyte is far past the point where an unread
619
+ # pipe blocks the writer and the tunnel stops moving bytes.
620
+ binary = _flooding_binary(tmp_path, "tcReal1", 1 << 20)
621
+ answer, serve = nat.begin({"kind": "tailcat", "ssh_port": 22, "binary": binary})
622
+ assert answer == {"address": "tcReal1"}
623
+
624
+ import threading
625
+
626
+ waiter = threading.Thread(target=serve, daemon=True)
627
+ waiter.start()
628
+ waiter.join(30.0)
629
+ assert not waiter.is_alive(), (
630
+ "tailcat serve never finished, so it blocked writing to a pipe nobody reads"
631
+ )
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