letify 1.2.2__tar.gz → 1.2.3__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.3}/PKG-INFO +1 -1
  2. {letify-1.2.2 → letify-1.2.3}/letify/__init__.py +1 -1
  3. {letify-1.2.2 → letify-1.2.3}/letify/store/volume.py +6 -2
  4. {letify-1.2.2 → letify-1.2.3}/letify/transport/nat.py +42 -10
  5. {letify-1.2.2 → letify-1.2.3}/letify-core/Cargo.lock +3 -3
  6. {letify-1.2.2 → letify-1.2.3}/letify-core/Cargo.toml +1 -1
  7. {letify-1.2.2 → letify-1.2.3}/letify-ext/package-lock.json +2 -2
  8. {letify-1.2.2 → letify-1.2.3}/letify-ext/package.json +1 -1
  9. {letify-1.2.2 → letify-1.2.3}/pyproject.toml +1 -1
  10. {letify-1.2.2 → letify-1.2.3}/tests/conftest.py +7 -0
  11. {letify-1.2.2 → letify-1.2.3}/tests/test_paths.py +35 -0
  12. {letify-1.2.2 → letify-1.2.3}/tests/test_strategies.py +37 -0
  13. {letify-1.2.2 → letify-1.2.3}/.github/workflows/ci.yml +0 -0
  14. {letify-1.2.2 → letify-1.2.3}/.github/workflows/docs.yml +0 -0
  15. {letify-1.2.2 → letify-1.2.3}/.github/workflows/publish.yml +0 -0
  16. {letify-1.2.2 → letify-1.2.3}/.gitignore +0 -0
  17. {letify-1.2.2 → letify-1.2.3}/.researchtree +0 -0
  18. {letify-1.2.2 → letify-1.2.3}/LICENSE +0 -0
  19. {letify-1.2.2 → letify-1.2.3}/README.md +0 -0
  20. {letify-1.2.2 → letify-1.2.3}/docs/guide/01-getting-started.md +0 -0
  21. {letify-1.2.2 → letify-1.2.3}/docs/guide/02-providers.md +0 -0
  22. {letify-1.2.2 → letify-1.2.3}/docs/guide/03-execution-modes.md +0 -0
  23. {letify-1.2.2 → letify-1.2.3}/docs/guide/04-environments-and-data.md +0 -0
  24. {letify-1.2.2 → letify-1.2.3}/docs/guide/05-concurrency.md +0 -0
  25. {letify-1.2.2 → letify-1.2.3}/docs/guide/06-cost.md +0 -0
  26. {letify-1.2.2 → letify-1.2.3}/docs/guide/07-troubleshooting.md +0 -0
  27. {letify-1.2.2 → letify-1.2.3}/docs/guide/08-releasing.md +0 -0
  28. {letify-1.2.2 → letify-1.2.3}/docs/guide/README.md +0 -0
  29. {letify-1.2.2 → letify-1.2.3}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  30. {letify-1.2.2 → letify-1.2.3}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  31. {letify-1.2.2 → letify-1.2.3}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  32. {letify-1.2.2 → letify-1.2.3}/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
  33. {letify-1.2.2 → letify-1.2.3}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  34. {letify-1.2.2 → letify-1.2.3}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  35. {letify-1.2.2 → letify-1.2.3}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  36. {letify-1.2.2 → letify-1.2.3}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  37. {letify-1.2.2 → letify-1.2.3}/docs/guide/ko/README.md +0 -0
  38. {letify-1.2.2 → letify-1.2.3}/docs/locales/README_ko.md +0 -0
  39. {letify-1.2.2 → letify-1.2.3}/docs/mkdocs.yml +0 -0
  40. {letify-1.2.2 → letify-1.2.3}/examples/README.md +0 -0
  41. {letify-1.2.2 → letify-1.2.3}/examples/nvfp4-lora/.letify.example +0 -0
  42. {letify-1.2.2 → letify-1.2.3}/examples/nvfp4-lora/00_smoke.py +0 -0
  43. {letify-1.2.2 → letify-1.2.3}/examples/nvfp4-lora/01_sweep.py +0 -0
  44. {letify-1.2.2 → letify-1.2.3}/examples/nvfp4-lora/02_resume.py +0 -0
  45. {letify-1.2.2 → letify-1.2.3}/examples/nvfp4-lora/03_watch.py +0 -0
  46. {letify-1.2.2 → letify-1.2.3}/examples/nvfp4-lora/README.md +0 -0
  47. {letify-1.2.2 → letify-1.2.3}/examples/nvfp4-lora/README_ko.md +0 -0
  48. {letify-1.2.2 → letify-1.2.3}/examples/nvfp4-lora/common.py +0 -0
  49. {letify-1.2.2 → letify-1.2.3}/examples/nvfp4-lora/recipe.py +0 -0
  50. {letify-1.2.2 → letify-1.2.3}/letify/cli.py +0 -0
  51. {letify-1.2.2 → letify-1.2.3}/letify/config/__init__.py +0 -0
  52. {letify-1.2.2 → letify-1.2.3}/letify/config/inventory.py +0 -0
  53. {letify-1.2.2 → letify-1.2.3}/letify/config/login.py +0 -0
  54. {letify-1.2.2 → letify-1.2.3}/letify/config/schema.py +0 -0
  55. {letify-1.2.2 → letify-1.2.3}/letify/config/secrets.py +0 -0
  56. {letify-1.2.2 → letify-1.2.3}/letify/config/writer.py +0 -0
  57. {letify-1.2.2 → letify-1.2.3}/letify/declare/__init__.py +0 -0
  58. {letify-1.2.2 → letify-1.2.3}/letify/declare/cache.py +0 -0
  59. {letify-1.2.2 → letify-1.2.3}/letify/declare/env.py +0 -0
  60. {letify-1.2.2 → letify-1.2.3}/letify/declare/function.py +0 -0
  61. {letify-1.2.2 → letify-1.2.3}/letify/declare/instance.py +0 -0
  62. {letify-1.2.2 → letify-1.2.3}/letify/errors.py +0 -0
  63. {letify-1.2.2 → letify-1.2.3}/letify/install.py +0 -0
  64. {letify-1.2.2 → letify-1.2.3}/letify/launcher.py +0 -0
  65. {letify-1.2.2 → letify-1.2.3}/letify/paths.py +0 -0
  66. {letify-1.2.2 → letify-1.2.3}/letify/protocol/__init__.py +0 -0
  67. {letify-1.2.2 → letify-1.2.3}/letify/protocol/codec.py +0 -0
  68. {letify-1.2.2 → letify-1.2.3}/letify/protocol/driver.py +0 -0
  69. {letify-1.2.2 → letify-1.2.3}/letify/protocol/handle.py +0 -0
  70. {letify-1.2.2 → letify-1.2.3}/letify/protocol/wire.py +0 -0
  71. {letify-1.2.2 → letify-1.2.3}/letify/protocol/worker.py +0 -0
  72. {letify-1.2.2 → letify-1.2.3}/letify/providers/__init__.py +0 -0
  73. {letify-1.2.2 → letify-1.2.3}/letify/providers/base.py +0 -0
  74. {letify-1.2.2 → letify-1.2.3}/letify/providers/colab.py +0 -0
  75. {letify-1.2.2 → letify-1.2.3}/letify/providers/colab_files.py +0 -0
  76. {letify-1.2.2 → letify-1.2.3}/letify/providers/colab_keepalive.py +0 -0
  77. {letify-1.2.2 → letify-1.2.3}/letify/providers/elice.py +0 -0
  78. {letify-1.2.2 → letify-1.2.3}/letify/providers/kaggle.py +0 -0
  79. {letify-1.2.2 → letify-1.2.3}/letify/providers/kaggle_adapter.py +0 -0
  80. {letify-1.2.2 → letify-1.2.3}/letify/providers/local.py +0 -0
  81. {letify-1.2.2 → letify-1.2.3}/letify/providers/modal.py +0 -0
  82. {letify-1.2.2 → letify-1.2.3}/letify/providers/modal_adapter.py +0 -0
  83. {letify-1.2.2 → letify-1.2.3}/letify/providers/naming.py +0 -0
  84. {letify-1.2.2 → letify-1.2.3}/letify/providers/shell.py +0 -0
  85. {letify-1.2.2 → letify-1.2.3}/letify/providers/tunnel.py +0 -0
  86. {letify-1.2.2 → letify-1.2.3}/letify/providers/usage.py +0 -0
  87. {letify-1.2.2 → letify-1.2.3}/letify/remoting/__init__.py +0 -0
  88. {letify-1.2.2 → letify-1.2.3}/letify/remoting/capability.py +0 -0
  89. {letify-1.2.2 → letify-1.2.3}/letify/remoting/device/__init__.py +0 -0
  90. {letify-1.2.2 → letify-1.2.3}/letify/remoting/device/client.py +0 -0
  91. {letify-1.2.2 → letify-1.2.3}/letify/remoting/device/cuda.py +0 -0
  92. {letify-1.2.2 → letify-1.2.3}/letify/remoting/device/executor.py +0 -0
  93. {letify-1.2.2 → letify-1.2.3}/letify/remoting/device/frames.py +0 -0
  94. {letify-1.2.2 → letify-1.2.3}/letify/remoting/device/guard.py +0 -0
  95. {letify-1.2.2 → letify-1.2.3}/letify/remoting/device/tensor.py +0 -0
  96. {letify-1.2.2 → letify-1.2.3}/letify/remoting/device/trace.py +0 -0
  97. {letify-1.2.2 → letify-1.2.3}/letify/remoting/device/value.py +0 -0
  98. {letify-1.2.2 → letify-1.2.3}/letify/remoting/loader.py +0 -0
  99. {letify-1.2.2 → letify-1.2.3}/letify/remoting/probe.py +0 -0
  100. {letify-1.2.2 → letify-1.2.3}/letify/render.py +0 -0
  101. {letify-1.2.2 → letify-1.2.3}/letify/runtime/__init__.py +0 -0
  102. {letify-1.2.2 → letify-1.2.3}/letify/runtime/bootstrap.py +0 -0
  103. {letify-1.2.2 → letify-1.2.3}/letify/runtime/channel.py +0 -0
  104. {letify-1.2.2 → letify-1.2.3}/letify/runtime/lease.py +0 -0
  105. {letify-1.2.2 → letify-1.2.3}/letify/runtime/pool.py +0 -0
  106. {letify-1.2.2 → letify-1.2.3}/letify/runtime/session.py +0 -0
  107. {letify-1.2.2 → letify-1.2.3}/letify/runtime/telemetry.py +0 -0
  108. {letify-1.2.2 → letify-1.2.3}/letify/store/__init__.py +0 -0
  109. {letify-1.2.2 → letify-1.2.3}/letify/store/backends/__init__.py +0 -0
  110. {letify-1.2.2 → letify-1.2.3}/letify/store/backends/filesystem.py +0 -0
  111. {letify-1.2.2 → letify-1.2.3}/letify/store/backends/google_auth.py +0 -0
  112. {letify-1.2.2 → letify-1.2.3}/letify/store/backends/layout.py +0 -0
  113. {letify-1.2.2 → letify-1.2.3}/letify/store/backends/objects.py +0 -0
  114. {letify-1.2.2 → letify-1.2.3}/letify/store/cas.py +0 -0
  115. {letify-1.2.2 → letify-1.2.3}/letify/store/pathdata.py +0 -0
  116. {letify-1.2.2 → letify-1.2.3}/letify/store/sendorder.py +0 -0
  117. {letify-1.2.2 → letify-1.2.3}/letify/stubs.py +0 -0
  118. {letify-1.2.2 → letify-1.2.3}/letify/tools.py +0 -0
  119. {letify-1.2.2 → letify-1.2.3}/letify/transport/__init__.py +0 -0
  120. {letify-1.2.2 → letify-1.2.3}/letify/transport/agent.py +0 -0
  121. {letify-1.2.2 → letify-1.2.3}/letify/transport/announce.py +0 -0
  122. {letify-1.2.2 → letify-1.2.3}/letify/transport/link.py +0 -0
  123. {letify-1.2.2 → letify-1.2.3}/letify/transport/pipeline.py +0 -0
  124. {letify-1.2.2 → letify-1.2.3}/letify/transport/probe.py +0 -0
  125. {letify-1.2.2 → letify-1.2.3}/letify/transport/rendezvous.py +0 -0
  126. {letify-1.2.2 → letify-1.2.3}/letify/transport/setup.py +0 -0
  127. {letify-1.2.2 → letify-1.2.3}/letify/transport/sshopts.py +0 -0
  128. {letify-1.2.2 → letify-1.2.3}/letify/transport/strategies.py +0 -0
  129. {letify-1.2.2 → letify-1.2.3}/letify-core/.gitignore +0 -0
  130. {letify-1.2.2 → letify-1.2.3}/letify-core/build.py +0 -0
  131. {letify-1.2.2 → letify-1.2.3}/letify-core/letify-agent/Cargo.toml +0 -0
  132. {letify-1.2.2 → letify-1.2.3}/letify-core/letify-agent/src/driver.rs +0 -0
  133. {letify-1.2.2 → letify-1.2.3}/letify-core/letify-agent/src/main.rs +0 -0
  134. {letify-1.2.2 → letify-1.2.3}/letify-core/letify-driver/Cargo.toml +0 -0
  135. {letify-1.2.2 → letify-1.2.3}/letify-core/letify-driver/src/client.rs +0 -0
  136. {letify-1.2.2 → letify-1.2.3}/letify-core/letify-driver/src/exports.rs +0 -0
  137. {letify-1.2.2 → letify-1.2.3}/letify-core/letify-driver/src/image.rs +0 -0
  138. {letify-1.2.2 → letify-1.2.3}/letify-core/letify-driver/src/lib.rs +0 -0
  139. {letify-1.2.2 → letify-1.2.3}/letify-core/letify-driver/src/status.rs +0 -0
  140. {letify-1.2.2 → letify-1.2.3}/letify-core/letify-driver/src/table.rs +0 -0
  141. {letify-1.2.2 → letify-1.2.3}/letify-core/letify-wire/Cargo.toml +0 -0
  142. {letify-1.2.2 → letify-1.2.3}/letify-core/letify-wire/src/lib.rs +0 -0
  143. {letify-1.2.2 → letify-1.2.3}/letify-core/letify-wire/tests/throughput.rs +0 -0
  144. {letify-1.2.2 → letify-1.2.3}/letify-core/rust-toolchain.toml +0 -0
  145. {letify-1.2.2 → letify-1.2.3}/letify-ext/.vscodeignore +0 -0
  146. {letify-1.2.2 → letify-1.2.3}/letify-ext/LICENSE +0 -0
  147. {letify-1.2.2 → letify-1.2.3}/letify-ext/README.md +0 -0
  148. {letify-1.2.2 → letify-1.2.3}/letify-ext/media/icon.svg +0 -0
  149. {letify-1.2.2 → letify-1.2.3}/letify-ext/src/cli.ts +0 -0
  150. {letify-1.2.2 → letify-1.2.3}/letify-ext/src/extension.ts +0 -0
  151. {letify-1.2.2 → letify-1.2.3}/letify-ext/src/history.ts +0 -0
  152. {letify-1.2.2 → letify-1.2.3}/letify-ext/src/model.ts +0 -0
  153. {letify-1.2.2 → letify-1.2.3}/letify-ext/src/poll.ts +0 -0
  154. {letify-1.2.2 → letify-1.2.3}/letify-ext/src/render.ts +0 -0
  155. {letify-1.2.2 → letify-1.2.3}/letify-ext/test/cli.test.ts +0 -0
  156. {letify-1.2.2 → letify-1.2.3}/letify-ext/test/manifest.test.ts +0 -0
  157. {letify-1.2.2 → letify-1.2.3}/letify-ext/test/model.test.ts +0 -0
  158. {letify-1.2.2 → letify-1.2.3}/letify-ext/test/regressions.test.ts +0 -0
  159. {letify-1.2.2 → letify-1.2.3}/letify-ext/tsconfig.json +0 -0
  160. {letify-1.2.2 → letify-1.2.3}/letify_providers.py +0 -0
  161. {letify-1.2.2 → letify-1.2.3}/scripts/sync-main.sh +0 -0
  162. {letify-1.2.2 → letify-1.2.3}/scripts/version.py +0 -0
  163. {letify-1.2.2 → letify-1.2.3}/tests/fake_kaggle_adapter.py +0 -0
  164. {letify-1.2.2 → letify-1.2.3}/tests/fake_modal_adapter.py +0 -0
  165. {letify-1.2.2 → letify-1.2.3}/tests/test_cli.py +0 -0
  166. {letify-1.2.2 → letify-1.2.3}/tests/test_cli_output.py +0 -0
  167. {letify-1.2.2 → letify-1.2.3}/tests/test_colab_keepalive.py +0 -0
  168. {letify-1.2.2 → letify-1.2.3}/tests/test_config.py +0 -0
  169. {letify-1.2.2 → letify-1.2.3}/tests/test_core.py +0 -0
  170. {letify-1.2.2 → letify-1.2.3}/tests/test_data.py +0 -0
  171. {letify-1.2.2 → letify-1.2.3}/tests/test_data_streaming.py +0 -0
  172. {letify-1.2.2 → letify-1.2.3}/tests/test_declare.py +0 -0
  173. {letify-1.2.2 → letify-1.2.3}/tests/test_device.py +0 -0
  174. {letify-1.2.2 → letify-1.2.3}/tests/test_editor_sessions.py +0 -0
  175. {letify-1.2.2 → letify-1.2.3}/tests/test_elice.py +0 -0
  176. {letify-1.2.2 → letify-1.2.3}/tests/test_frames.py +0 -0
  177. {letify-1.2.2 → letify-1.2.3}/tests/test_install.py +0 -0
  178. {letify-1.2.2 → letify-1.2.3}/tests/test_kaggle.py +0 -0
  179. {letify-1.2.2 → letify-1.2.3}/tests/test_kaggle_cookie.py +0 -0
  180. {letify-1.2.2 → letify-1.2.3}/tests/test_link_log.py +0 -0
  181. {letify-1.2.2 → letify-1.2.3}/tests/test_notebook.py +0 -0
  182. {letify-1.2.2 → letify-1.2.3}/tests/test_protocol.py +0 -0
  183. {letify-1.2.2 → letify-1.2.3}/tests/test_providers.py +0 -0
  184. {letify-1.2.2 → letify-1.2.3}/tests/test_remoting.py +0 -0
  185. {letify-1.2.2 → letify-1.2.3}/tests/test_render.py +0 -0
  186. {letify-1.2.2 → letify-1.2.3}/tests/test_runtime.py +0 -0
  187. {letify-1.2.2 → letify-1.2.3}/tests/test_ship.py +0 -0
  188. {letify-1.2.2 → letify-1.2.3}/tests/test_store.py +0 -0
  189. {letify-1.2.2 → letify-1.2.3}/tests/test_stubs.py +0 -0
  190. {letify-1.2.2 → letify-1.2.3}/tests/test_transport.py +0 -0
  191. {letify-1.2.2 → letify-1.2.3}/tests/test_tunnel_setup.py +0 -0
  192. {letify-1.2.2 → letify-1.2.3}/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.3
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.3"
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)
@@ -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.3"
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.3"
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.3"
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.3"
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.3",
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.3",
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.3",
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.3"
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
 
@@ -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
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