letify 2.4.0__tar.gz → 2.4.2__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 (200) hide show
  1. {letify-2.4.0 → letify-2.4.2}/PKG-INFO +1 -1
  2. {letify-2.4.0 → letify-2.4.2}/letify/__init__.py +1 -1
  3. {letify-2.4.0 → letify-2.4.2}/letify/cli.py +62 -0
  4. {letify-2.4.0 → letify-2.4.2}/letify/install.py +31 -0
  5. {letify-2.4.0 → letify-2.4.2}/letify/launcher.py +34 -0
  6. {letify-2.4.0 → letify-2.4.2}/letify/protocol/worker.py +107 -13
  7. {letify-2.4.0 → letify-2.4.2}/letify/providers/base.py +12 -0
  8. {letify-2.4.0 → letify-2.4.2}/letify/providers/kaggle.py +182 -21
  9. {letify-2.4.0 → letify-2.4.2}/letify-core/Cargo.lock +3 -3
  10. {letify-2.4.0 → letify-2.4.2}/letify-core/Cargo.toml +1 -1
  11. {letify-2.4.0 → letify-2.4.2}/letify-ext/package-lock.json +2 -2
  12. {letify-2.4.0 → letify-2.4.2}/letify-ext/package.json +1 -1
  13. {letify-2.4.0 → letify-2.4.2}/pyproject.toml +1 -1
  14. {letify-2.4.0 → letify-2.4.2}/tests/conftest.py +3 -0
  15. {letify-2.4.0 → letify-2.4.2}/tests/test_cli.py +75 -0
  16. {letify-2.4.0 → letify-2.4.2}/tests/test_data_streaming.py +128 -0
  17. {letify-2.4.0 → letify-2.4.2}/tests/test_install.py +42 -0
  18. {letify-2.4.0 → letify-2.4.2}/tests/test_kaggle.py +157 -0
  19. {letify-2.4.0 → letify-2.4.2}/tests/test_parallel_placement.py +52 -0
  20. {letify-2.4.0 → letify-2.4.2}/.github/workflows/ci.yml +0 -0
  21. {letify-2.4.0 → letify-2.4.2}/.github/workflows/docs.yml +0 -0
  22. {letify-2.4.0 → letify-2.4.2}/.github/workflows/publish.yml +0 -0
  23. {letify-2.4.0 → letify-2.4.2}/.gitignore +0 -0
  24. {letify-2.4.0 → letify-2.4.2}/.researchtree +0 -0
  25. {letify-2.4.0 → letify-2.4.2}/LICENSE +0 -0
  26. {letify-2.4.0 → letify-2.4.2}/README.md +0 -0
  27. {letify-2.4.0 → letify-2.4.2}/docs/guide/01-getting-started.md +0 -0
  28. {letify-2.4.0 → letify-2.4.2}/docs/guide/02-providers.md +0 -0
  29. {letify-2.4.0 → letify-2.4.2}/docs/guide/03-execution-modes.md +0 -0
  30. {letify-2.4.0 → letify-2.4.2}/docs/guide/04-environments-and-data.md +0 -0
  31. {letify-2.4.0 → letify-2.4.2}/docs/guide/05-concurrency.md +0 -0
  32. {letify-2.4.0 → letify-2.4.2}/docs/guide/06-cost.md +0 -0
  33. {letify-2.4.0 → letify-2.4.2}/docs/guide/07-troubleshooting.md +0 -0
  34. {letify-2.4.0 → letify-2.4.2}/docs/guide/08-releasing.md +0 -0
  35. {letify-2.4.0 → letify-2.4.2}/docs/guide/README.md +0 -0
  36. {letify-2.4.0 → letify-2.4.2}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  37. {letify-2.4.0 → letify-2.4.2}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  38. {letify-2.4.0 → letify-2.4.2}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  39. {letify-2.4.0 → letify-2.4.2}/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
  40. {letify-2.4.0 → letify-2.4.2}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  41. {letify-2.4.0 → letify-2.4.2}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  42. {letify-2.4.0 → letify-2.4.2}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  43. {letify-2.4.0 → letify-2.4.2}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  44. {letify-2.4.0 → letify-2.4.2}/docs/guide/ko/README.md +0 -0
  45. {letify-2.4.0 → letify-2.4.2}/docs/locales/README_ko.md +0 -0
  46. {letify-2.4.0 → letify-2.4.2}/docs/mkdocs.yml +0 -0
  47. {letify-2.4.0 → letify-2.4.2}/examples/README.md +0 -0
  48. {letify-2.4.0 → letify-2.4.2}/examples/nvfp4-lora/.letify.example +0 -0
  49. {letify-2.4.0 → letify-2.4.2}/examples/nvfp4-lora/00_smoke.py +0 -0
  50. {letify-2.4.0 → letify-2.4.2}/examples/nvfp4-lora/01_sweep.py +0 -0
  51. {letify-2.4.0 → letify-2.4.2}/examples/nvfp4-lora/02_resume.py +0 -0
  52. {letify-2.4.0 → letify-2.4.2}/examples/nvfp4-lora/03_watch.py +0 -0
  53. {letify-2.4.0 → letify-2.4.2}/examples/nvfp4-lora/README.md +0 -0
  54. {letify-2.4.0 → letify-2.4.2}/examples/nvfp4-lora/README_ko.md +0 -0
  55. {letify-2.4.0 → letify-2.4.2}/examples/nvfp4-lora/common.py +0 -0
  56. {letify-2.4.0 → letify-2.4.2}/examples/nvfp4-lora/recipe.py +0 -0
  57. {letify-2.4.0 → letify-2.4.2}/letify/config/__init__.py +0 -0
  58. {letify-2.4.0 → letify-2.4.2}/letify/config/inventory.py +0 -0
  59. {letify-2.4.0 → letify-2.4.2}/letify/config/login.py +0 -0
  60. {letify-2.4.0 → letify-2.4.2}/letify/config/schema.py +0 -0
  61. {letify-2.4.0 → letify-2.4.2}/letify/config/secrets.py +0 -0
  62. {letify-2.4.0 → letify-2.4.2}/letify/config/writer.py +0 -0
  63. {letify-2.4.0 → letify-2.4.2}/letify/declare/__init__.py +0 -0
  64. {letify-2.4.0 → letify-2.4.2}/letify/declare/cache.py +0 -0
  65. {letify-2.4.0 → letify-2.4.2}/letify/declare/env.py +0 -0
  66. {letify-2.4.0 → letify-2.4.2}/letify/declare/function.py +0 -0
  67. {letify-2.4.0 → letify-2.4.2}/letify/declare/instance.py +0 -0
  68. {letify-2.4.0 → letify-2.4.2}/letify/errors.py +0 -0
  69. {letify-2.4.0 → letify-2.4.2}/letify/paths.py +0 -0
  70. {letify-2.4.0 → letify-2.4.2}/letify/protocol/__init__.py +0 -0
  71. {letify-2.4.0 → letify-2.4.2}/letify/protocol/codec.py +0 -0
  72. {letify-2.4.0 → letify-2.4.2}/letify/protocol/driver.py +0 -0
  73. {letify-2.4.0 → letify-2.4.2}/letify/protocol/handle.py +0 -0
  74. {letify-2.4.0 → letify-2.4.2}/letify/protocol/wire.py +0 -0
  75. {letify-2.4.0 → letify-2.4.2}/letify/providers/__init__.py +0 -0
  76. {letify-2.4.0 → letify-2.4.2}/letify/providers/colab.py +0 -0
  77. {letify-2.4.0 → letify-2.4.2}/letify/providers/colab_files.py +0 -0
  78. {letify-2.4.0 → letify-2.4.2}/letify/providers/colab_keepalive.py +0 -0
  79. {letify-2.4.0 → letify-2.4.2}/letify/providers/elice.py +0 -0
  80. {letify-2.4.0 → letify-2.4.2}/letify/providers/kaggle_adapter.py +0 -0
  81. {letify-2.4.0 → letify-2.4.2}/letify/providers/local.py +0 -0
  82. {letify-2.4.0 → letify-2.4.2}/letify/providers/modal.py +0 -0
  83. {letify-2.4.0 → letify-2.4.2}/letify/providers/modal_adapter.py +0 -0
  84. {letify-2.4.0 → letify-2.4.2}/letify/providers/naming.py +0 -0
  85. {letify-2.4.0 → letify-2.4.2}/letify/providers/shell.py +0 -0
  86. {letify-2.4.0 → letify-2.4.2}/letify/providers/tunnel.py +0 -0
  87. {letify-2.4.0 → letify-2.4.2}/letify/providers/usage.py +0 -0
  88. {letify-2.4.0 → letify-2.4.2}/letify/remoting/__init__.py +0 -0
  89. {letify-2.4.0 → letify-2.4.2}/letify/remoting/capability.py +0 -0
  90. {letify-2.4.0 → letify-2.4.2}/letify/remoting/device/__init__.py +0 -0
  91. {letify-2.4.0 → letify-2.4.2}/letify/remoting/device/client.py +0 -0
  92. {letify-2.4.0 → letify-2.4.2}/letify/remoting/device/cuda.py +0 -0
  93. {letify-2.4.0 → letify-2.4.2}/letify/remoting/device/executor.py +0 -0
  94. {letify-2.4.0 → letify-2.4.2}/letify/remoting/device/frames.py +0 -0
  95. {letify-2.4.0 → letify-2.4.2}/letify/remoting/device/guard.py +0 -0
  96. {letify-2.4.0 → letify-2.4.2}/letify/remoting/device/tensor.py +0 -0
  97. {letify-2.4.0 → letify-2.4.2}/letify/remoting/device/trace.py +0 -0
  98. {letify-2.4.0 → letify-2.4.2}/letify/remoting/device/value.py +0 -0
  99. {letify-2.4.0 → letify-2.4.2}/letify/remoting/loader.py +0 -0
  100. {letify-2.4.0 → letify-2.4.2}/letify/remoting/probe.py +0 -0
  101. {letify-2.4.0 → letify-2.4.2}/letify/render.py +0 -0
  102. {letify-2.4.0 → letify-2.4.2}/letify/runtime/__init__.py +0 -0
  103. {letify-2.4.0 → letify-2.4.2}/letify/runtime/bootstrap.py +0 -0
  104. {letify-2.4.0 → letify-2.4.2}/letify/runtime/channel.py +0 -0
  105. {letify-2.4.0 → letify-2.4.2}/letify/runtime/lease.py +0 -0
  106. {letify-2.4.0 → letify-2.4.2}/letify/runtime/pool.py +0 -0
  107. {letify-2.4.0 → letify-2.4.2}/letify/runtime/session.py +0 -0
  108. {letify-2.4.0 → letify-2.4.2}/letify/runtime/telemetry.py +0 -0
  109. {letify-2.4.0 → letify-2.4.2}/letify/store/__init__.py +0 -0
  110. {letify-2.4.0 → letify-2.4.2}/letify/store/backends/__init__.py +0 -0
  111. {letify-2.4.0 → letify-2.4.2}/letify/store/backends/filesystem.py +0 -0
  112. {letify-2.4.0 → letify-2.4.2}/letify/store/backends/google_auth.py +0 -0
  113. {letify-2.4.0 → letify-2.4.2}/letify/store/backends/layout.py +0 -0
  114. {letify-2.4.0 → letify-2.4.2}/letify/store/backends/objects.py +0 -0
  115. {letify-2.4.0 → letify-2.4.2}/letify/store/cas.py +0 -0
  116. {letify-2.4.0 → letify-2.4.2}/letify/store/pathdata.py +0 -0
  117. {letify-2.4.0 → letify-2.4.2}/letify/store/sendorder.py +0 -0
  118. {letify-2.4.0 → letify-2.4.2}/letify/store/volume.py +0 -0
  119. {letify-2.4.0 → letify-2.4.2}/letify/stubs.py +0 -0
  120. {letify-2.4.0 → letify-2.4.2}/letify/tools.py +0 -0
  121. {letify-2.4.0 → letify-2.4.2}/letify/transport/__init__.py +0 -0
  122. {letify-2.4.0 → letify-2.4.2}/letify/transport/agent.py +0 -0
  123. {letify-2.4.0 → letify-2.4.2}/letify/transport/announce.py +0 -0
  124. {letify-2.4.0 → letify-2.4.2}/letify/transport/link.py +0 -0
  125. {letify-2.4.0 → letify-2.4.2}/letify/transport/nat.py +0 -0
  126. {letify-2.4.0 → letify-2.4.2}/letify/transport/pipeline.py +0 -0
  127. {letify-2.4.0 → letify-2.4.2}/letify/transport/portmap.py +0 -0
  128. {letify-2.4.0 → letify-2.4.2}/letify/transport/probe.py +0 -0
  129. {letify-2.4.0 → letify-2.4.2}/letify/transport/quic.py +0 -0
  130. {letify-2.4.0 → letify-2.4.2}/letify/transport/rendezvous.py +0 -0
  131. {letify-2.4.0 → letify-2.4.2}/letify/transport/setup.py +0 -0
  132. {letify-2.4.0 → letify-2.4.2}/letify/transport/sshopts.py +0 -0
  133. {letify-2.4.0 → letify-2.4.2}/letify/transport/strategies.py +0 -0
  134. {letify-2.4.0 → letify-2.4.2}/letify-core/.gitignore +0 -0
  135. {letify-2.4.0 → letify-2.4.2}/letify-core/build.py +0 -0
  136. {letify-2.4.0 → letify-2.4.2}/letify-core/letify-agent/Cargo.toml +0 -0
  137. {letify-2.4.0 → letify-2.4.2}/letify-core/letify-agent/src/driver.rs +0 -0
  138. {letify-2.4.0 → letify-2.4.2}/letify-core/letify-agent/src/main.rs +0 -0
  139. {letify-2.4.0 → letify-2.4.2}/letify-core/letify-driver/Cargo.toml +0 -0
  140. {letify-2.4.0 → letify-2.4.2}/letify-core/letify-driver/src/client.rs +0 -0
  141. {letify-2.4.0 → letify-2.4.2}/letify-core/letify-driver/src/exports.rs +0 -0
  142. {letify-2.4.0 → letify-2.4.2}/letify-core/letify-driver/src/image.rs +0 -0
  143. {letify-2.4.0 → letify-2.4.2}/letify-core/letify-driver/src/lib.rs +0 -0
  144. {letify-2.4.0 → letify-2.4.2}/letify-core/letify-driver/src/status.rs +0 -0
  145. {letify-2.4.0 → letify-2.4.2}/letify-core/letify-driver/src/table.rs +0 -0
  146. {letify-2.4.0 → letify-2.4.2}/letify-core/letify-quic/Cargo.toml +0 -0
  147. {letify-2.4.0 → letify-2.4.2}/letify-core/letify-quic/src/main.rs +0 -0
  148. {letify-2.4.0 → letify-2.4.2}/letify-core/letify-wire/Cargo.toml +0 -0
  149. {letify-2.4.0 → letify-2.4.2}/letify-core/letify-wire/src/lib.rs +0 -0
  150. {letify-2.4.0 → letify-2.4.2}/letify-core/letify-wire/tests/throughput.rs +0 -0
  151. {letify-2.4.0 → letify-2.4.2}/letify-core/rust-toolchain.toml +0 -0
  152. {letify-2.4.0 → letify-2.4.2}/letify-ext/.vscodeignore +0 -0
  153. {letify-2.4.0 → letify-2.4.2}/letify-ext/LICENSE +0 -0
  154. {letify-2.4.0 → letify-2.4.2}/letify-ext/README.md +0 -0
  155. {letify-2.4.0 → letify-2.4.2}/letify-ext/media/icon.svg +0 -0
  156. {letify-2.4.0 → letify-2.4.2}/letify-ext/src/cli.ts +0 -0
  157. {letify-2.4.0 → letify-2.4.2}/letify-ext/src/extension.ts +0 -0
  158. {letify-2.4.0 → letify-2.4.2}/letify-ext/src/history.ts +0 -0
  159. {letify-2.4.0 → letify-2.4.2}/letify-ext/src/model.ts +0 -0
  160. {letify-2.4.0 → letify-2.4.2}/letify-ext/src/poll.ts +0 -0
  161. {letify-2.4.0 → letify-2.4.2}/letify-ext/src/render.ts +0 -0
  162. {letify-2.4.0 → letify-2.4.2}/letify-ext/test/cli.test.ts +0 -0
  163. {letify-2.4.0 → letify-2.4.2}/letify-ext/test/manifest.test.ts +0 -0
  164. {letify-2.4.0 → letify-2.4.2}/letify-ext/test/model.test.ts +0 -0
  165. {letify-2.4.0 → letify-2.4.2}/letify-ext/test/regressions.test.ts +0 -0
  166. {letify-2.4.0 → letify-2.4.2}/letify-ext/tsconfig.json +0 -0
  167. {letify-2.4.0 → letify-2.4.2}/letify_providers.py +0 -0
  168. {letify-2.4.0 → letify-2.4.2}/scripts/sync-main.sh +0 -0
  169. {letify-2.4.0 → letify-2.4.2}/scripts/version.py +0 -0
  170. {letify-2.4.0 → letify-2.4.2}/tests/fake_kaggle_adapter.py +0 -0
  171. {letify-2.4.0 → letify-2.4.2}/tests/fake_modal_adapter.py +0 -0
  172. {letify-2.4.0 → letify-2.4.2}/tests/test_cli_output.py +0 -0
  173. {letify-2.4.0 → letify-2.4.2}/tests/test_colab_keepalive.py +0 -0
  174. {letify-2.4.0 → letify-2.4.2}/tests/test_config.py +0 -0
  175. {letify-2.4.0 → letify-2.4.2}/tests/test_core.py +0 -0
  176. {letify-2.4.0 → letify-2.4.2}/tests/test_data.py +0 -0
  177. {letify-2.4.0 → letify-2.4.2}/tests/test_declare.py +0 -0
  178. {letify-2.4.0 → letify-2.4.2}/tests/test_device.py +0 -0
  179. {letify-2.4.0 → letify-2.4.2}/tests/test_editor_sessions.py +0 -0
  180. {letify-2.4.0 → letify-2.4.2}/tests/test_elice.py +0 -0
  181. {letify-2.4.0 → letify-2.4.2}/tests/test_frames.py +0 -0
  182. {letify-2.4.0 → letify-2.4.2}/tests/test_kaggle_cookie.py +0 -0
  183. {letify-2.4.0 → letify-2.4.2}/tests/test_kaggle_link.py +0 -0
  184. {letify-2.4.0 → letify-2.4.2}/tests/test_link_log.py +0 -0
  185. {letify-2.4.0 → letify-2.4.2}/tests/test_notebook.py +0 -0
  186. {letify-2.4.0 → letify-2.4.2}/tests/test_paths.py +0 -0
  187. {letify-2.4.0 → letify-2.4.2}/tests/test_portmap.py +0 -0
  188. {letify-2.4.0 → letify-2.4.2}/tests/test_protocol.py +0 -0
  189. {letify-2.4.0 → letify-2.4.2}/tests/test_providers.py +0 -0
  190. {letify-2.4.0 → letify-2.4.2}/tests/test_quic.py +0 -0
  191. {letify-2.4.0 → letify-2.4.2}/tests/test_remoting.py +0 -0
  192. {letify-2.4.0 → letify-2.4.2}/tests/test_render.py +0 -0
  193. {letify-2.4.0 → letify-2.4.2}/tests/test_runtime.py +0 -0
  194. {letify-2.4.0 → letify-2.4.2}/tests/test_ship.py +0 -0
  195. {letify-2.4.0 → letify-2.4.2}/tests/test_store.py +0 -0
  196. {letify-2.4.0 → letify-2.4.2}/tests/test_strategies.py +0 -0
  197. {letify-2.4.0 → letify-2.4.2}/tests/test_stubs.py +0 -0
  198. {letify-2.4.0 → letify-2.4.2}/tests/test_transport.py +0 -0
  199. {letify-2.4.0 → letify-2.4.2}/tests/test_tunnel_setup.py +0 -0
  200. {letify-2.4.0 → letify-2.4.2}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.4.0
3
+ Version: 2.4.2
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__ = "2.4.0"
65
+ __version__ = "2.4.2"
66
66
 
67
67
 
68
68
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -40,6 +40,16 @@ def build_parser() -> argparse.ArgumentParser:
40
40
  sessions_parser.add_argument(
41
41
  "--json", action="store_true", help="print the records unformatted"
42
42
  )
43
+ stop = sub.add_parser("stop", help="stop what a dead process left behind")
44
+ stop.add_argument(
45
+ "--orphans",
46
+ action="store_true",
47
+ help="delete the sessions whose creating process is gone",
48
+ )
49
+ stop.add_argument("--alias", help="only this account")
50
+ stop.add_argument("-y", "--yes", action="store_true", help="do not ask")
51
+ stop.add_argument("--json", action="store_true", help="print the records unformatted")
52
+
43
53
  sub.add_parser("stubs", help="write the provider types an editor completes")
44
54
 
45
55
  usage = sub.add_parser("usage", help="show what each account has left")
@@ -523,6 +533,25 @@ def _say(kind: str, message: str) -> None:
523
533
  print(f"{render.mark(kind, _out())} {message}")
524
534
 
525
535
 
536
+ def _detail(row: dict) -> str:
537
+ """The reason a stop failed, when there is one to add."""
538
+ return f": {row['error']}" if row.get("error") else ""
539
+
540
+
541
+ def _confirmed(question: str) -> bool:
542
+ """Ask before deleting. Anything but yes is no, and no terminal is no."""
543
+ import sys as _sys
544
+
545
+ if not _sys.stdin.isatty():
546
+ print(f"{question} not a terminal, so nothing is assumed. Pass -y to delete")
547
+ return False
548
+ try:
549
+ answer = input(f"{question} [y/N] ").strip().lower()
550
+ except EOFError:
551
+ return False
552
+ return answer in ("y", "yes")
553
+
554
+
526
555
  def _json(value: object) -> int:
527
556
  print(json.dumps(value, indent=2))
528
557
  return 0
@@ -679,6 +708,39 @@ def _dispatch(args: argparse.Namespace) -> int:
679
708
  print(f"{row['alias']} {detail}")
680
709
  return 0
681
710
 
711
+ if args.command == "stop":
712
+ if not args.orphans:
713
+ # A bare `stop` reads as ending everything, so it ends nothing.
714
+ print("nothing was stopped. Pass --orphans to delete what a dead process left")
715
+ return 0
716
+ rows = let.orphans(args.alias)
717
+ found = [(row["alias"], ref) for row in rows for ref in row.get("orphans", ())]
718
+ unavailable = [row for row in rows if row.get("unavailable")]
719
+ if args.json and not found:
720
+ return _json(rows)
721
+ for row in unavailable:
722
+ print(f"{row['alias']} {row['unavailable']}")
723
+ if not found:
724
+ print("no orphaned sessions")
725
+ return 0
726
+ print(f"{len(found)} orphaned session(s) to delete:")
727
+ for alias, ref in found:
728
+ print(f" {alias} {ref}")
729
+ if not args.yes and not _confirmed("Delete them?"):
730
+ print("nothing was stopped")
731
+ return 0
732
+ done = let.stop_orphans(args.alias)
733
+ if args.json:
734
+ return _json(done)
735
+ failed = 0
736
+ for row in done:
737
+ if row.get("unavailable"):
738
+ continue
739
+ mark = "stopped" if row.get("stopped") else f"failed{_detail(row)}"
740
+ failed += 0 if row.get("stopped") else 1
741
+ print(f"{row['alias']} {row['ref']} {mark}")
742
+ return 1 if failed else 0
743
+
682
744
  if args.command == "status":
683
745
  status = let.status()
684
746
  if args.json:
@@ -37,6 +37,10 @@ from .transport.setup import TAILCAT_VERSION
37
37
 
38
38
  #: Seconds to wait for a release server.
39
39
  DOWNLOAD_TIMEOUT = 60.0
40
+ #: Seconds to wait for the connection alone. A host a network blocks has to fail in
41
+ #: seconds, not after the read timeout, and twice that when a release redirects.
42
+ #: Spec "Automatic install".
43
+ CONNECT_TIMEOUT = 10.0
40
44
 
41
45
  #: Bytes read from a release server at a time, so progress can be reported.
42
46
  DOWNLOAD_CHUNK = 64 << 10
@@ -352,8 +356,35 @@ def _isatty(stream) -> bool:
352
356
  return False
353
357
 
354
358
 
359
+ def _reachable(url: str) -> None:
360
+ """Refuse a download whose host does not answer, before the request is made.
361
+
362
+ Spec "Automatic install": the error names the host so a blocked network is told what
363
+ to open or to configure around, rather than being told the tool is broken.
364
+ """
365
+ import socket
366
+ import urllib.parse
367
+
368
+ parsed = urllib.parse.urlsplit(url)
369
+ host = parsed.hostname
370
+ if not host:
371
+ return
372
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
373
+ try:
374
+ socket.create_connection((host, port), timeout=CONNECT_TIMEOUT).close()
375
+ except OSError as exc:
376
+ raise InstallError(
377
+ f"could not reach {host}:{port} within {CONNECT_TIMEOUT:.0f} s ({exc}), so "
378
+ f"{url} was not downloaded. The host, not letify, is what is unreachable: a "
379
+ f"network that allows github.com can still block the host its releases "
380
+ f"redirect to. Fetch the archive another way and name the binary with the "
381
+ f"account's tailcat_binary or eci_binary"
382
+ ) from None
383
+
384
+
355
385
  def _download(url: str, *, label: str | None = None, stream=None) -> bytes:
356
386
  """Fetch ``url``; with ``label``, report progress on ``stream`` (standard error)."""
387
+ _reachable(url)
357
388
  try:
358
389
  with urllib.request.urlopen(url, timeout=DOWNLOAD_TIMEOUT) as response:
359
390
  if label is None:
@@ -499,6 +499,40 @@ class Launcher:
499
499
  rows.append({"alias": alias, "sessions": [], "unavailable": str(exc)})
500
500
  return rows
501
501
 
502
+ def orphans(self, alias: str | None = None) -> list[dict[str, Any]]:
503
+ """What a dead process left behind, per account.
504
+
505
+ Spec "Kaggle", stopping what a dead process left behind.
506
+ """
507
+ rows: list[dict[str, Any]] = []
508
+ for name in self.config.order:
509
+ if alias is not None and name != alias:
510
+ continue
511
+ try:
512
+ provider = self.provider(name)
513
+ rows.append({"alias": name, "kind": provider.kind, "orphans": provider.orphans()})
514
+ except LetifyError as exc:
515
+ rows.append({"alias": name, "orphans": [], "unavailable": str(exc)})
516
+ return rows
517
+
518
+ def stop_orphans(self, alias: str | None = None) -> list[dict[str, Any]]:
519
+ """Delete what ``orphans`` reported, one at a time, reporting each outcome."""
520
+ done: list[dict[str, Any]] = []
521
+ for row in self.orphans(alias):
522
+ if row.get("unavailable"):
523
+ done.append(row)
524
+ continue
525
+ provider = self.provider(row["alias"])
526
+ for ref in row["orphans"]:
527
+ try:
528
+ gone = provider.stop_orphan(ref)
529
+ done.append({"alias": row["alias"], "ref": ref, "stopped": gone})
530
+ except LetifyError as exc:
531
+ done.append(
532
+ {"alias": row["alias"], "ref": ref, "stopped": False, "error": str(exc)}
533
+ )
534
+ return done
535
+
502
536
  def status(self) -> dict[str, Any]:
503
537
  """What is running right now, and what it is costing.
504
538
 
@@ -953,9 +953,12 @@ def _data_register(data):
953
953
  # A call still waiting for bytes needs the patch to answer from the manifest,
954
954
  # and a call that placed a link needs it for copy on write. Spec "Copy on write".
955
955
  _data_install_patch()
956
- if state["pending"]:
957
- # Only a call still waiting for bytes needs the manifest on disk and the
958
- # wrappers. Inside the lock: the data thread moves a path out of pending and
956
+ if state["pending"] or state["linked"]:
957
+ # A call still waiting for bytes needs the manifest on disk for the listings,
958
+ # and a call that placed a link needs it so a process the body starts can
959
+ # convert that link before writing through it: the patch a fresh process
960
+ # installs reads the layout from the manifest and installs nothing without it.
961
+ # Spec "Copy on write". Inside the lock: the data thread moves a path out of pending and
959
962
  # into placed as each blob completes, and the manifest is written from both maps.
960
963
  _data_write_manifest(state)
961
964
  _data_observe(state)
@@ -1416,7 +1419,7 @@ def _data_write_manifest(state):
1416
1419
  """Write the call's manifest beside its files, for a process started fresh.
1417
1420
 
1418
1421
  Spec "What the body sees before a file arrives": a spawned interpreter installs the
1419
- same patch from ``letify_pending.pth`` and reads the layout from here, because it has
1422
+ same patch from ``sitecustomize.py`` and reads the layout from here, because it has
1420
1423
  no channel of its own to ask on.
1421
1424
  """
1422
1425
  import json
@@ -1435,10 +1438,11 @@ def _data_write_manifest(state):
1435
1438
  directory = os.path.join(os.path.dirname(state["blobs"]), "pending")
1436
1439
  try:
1437
1440
  os.makedirs(directory, exist_ok=True)
1438
- with open(os.path.join(directory, "letify_pending.py"), "w") as handle:
1441
+ # sitecustomize, not a .pth: site.py runs a .pth only in a directory it adds
1442
+ # through addsitedir, and a PYTHONPATH entry is not one, so a .pth there never
1443
+ # runs. Spec "What the body sees before a file arrives".
1444
+ with open(os.path.join(directory, "sitecustomize.py"), "w") as handle:
1439
1445
  handle.write(_PENDING_MODULE)
1440
- with open(os.path.join(directory, "letify_pending.pth"), "w") as handle:
1441
- handle.write("import letify_pending\n")
1442
1446
  except OSError:
1443
1447
  return
1444
1448
  existing = os.environ.get("PYTHONPATH", "")
@@ -1457,6 +1461,8 @@ import builtins
1457
1461
  import io
1458
1462
  import json
1459
1463
  import os
1464
+ import shutil
1465
+ import sys
1460
1466
  import time
1461
1467
 
1462
1468
 
@@ -1474,13 +1480,25 @@ def _install():
1474
1480
  real_open, real_io_open = builtins.open, io.open
1475
1481
  real_listdir, real_scandir = os.listdir, os.scandir
1476
1482
  real_stat = os.stat
1483
+ real_os_open = os.open
1484
+ write_flags = os.O_WRONLY | os.O_RDWR | os.O_APPEND | os.O_TRUNC
1485
+
1486
+ # Whether the file exists, asked through the real stat. os.path.exists goes through
1487
+ # os.stat, and os.stat is what this patch replaces, so asking it here would call the
1488
+ # patch again and recurse without bottom.
1489
+ def there(text):
1490
+ try:
1491
+ real_stat(text)
1492
+ except OSError:
1493
+ return False
1494
+ return True
1477
1495
 
1478
1496
  def missing(path):
1479
1497
  try:
1480
1498
  text = os.path.abspath(os.fspath(path))
1481
1499
  except TypeError:
1482
1500
  return None
1483
- if not text.startswith(call_dir) or os.path.exists(text):
1501
+ if not text.startswith(call_dir) or there(text):
1484
1502
  return None
1485
1503
  return text if text in files else None
1486
1504
 
@@ -1489,19 +1507,64 @@ def _install():
1489
1507
  if text is None:
1490
1508
  return
1491
1509
  deadline = time.monotonic() + wait
1492
- while not os.path.exists(text):
1510
+ while not there(text):
1493
1511
  if time.monotonic() >= deadline:
1494
1512
  raise RuntimeError("letify: %s did not arrive within %.0f s" % (text, wait))
1495
1513
  time.sleep(0.02)
1496
1514
 
1515
+ # A placed file is a hard link to the cache blob, so a write through it writes into
1516
+ # the cache. The worker converts the link first and this has to as well; it cannot read
1517
+ # the worker's placed map, so a link count above one under the call directory is what
1518
+ # it goes on. Spec "What the body sees before a file arrives".
1519
+ def convert(path):
1520
+ try:
1521
+ text = os.path.abspath(os.fspath(path))
1522
+ except TypeError:
1523
+ return
1524
+ if not text.startswith(call_dir):
1525
+ return
1526
+ try:
1527
+ if real_stat(text).st_nlink < 2:
1528
+ return
1529
+ except OSError:
1530
+ return
1531
+ partial = '%s.letify-placing.%d' % (text, os.getpid())
1532
+ try:
1533
+ shutil.copyfile(text, partial)
1534
+ os.replace(partial, text)
1535
+ except OSError:
1536
+ try:
1537
+ os.remove(partial)
1538
+ except OSError:
1539
+ pass
1540
+
1541
+ def writes(mode):
1542
+ return any(letter in mode for letter in ('w', 'a', 'x', '+'))
1543
+
1544
+ def ready(path, writing):
1545
+ wait_for(path)
1546
+ if writing:
1547
+ convert(path)
1548
+
1497
1549
  def opened(file, *args, **kwargs):
1498
- wait_for(file)
1550
+ mode = kwargs['mode'] if 'mode' in kwargs else (args[0] if args else 'r')
1551
+ ready(file, writes(mode) if isinstance(mode, str) else False)
1499
1552
  return real_open(file, *args, **kwargs)
1500
1553
 
1501
1554
  def io_opened(file, *args, **kwargs):
1502
- wait_for(file)
1555
+ mode = kwargs['mode'] if 'mode' in kwargs else (args[0] if args else 'r')
1556
+ ready(file, writes(mode) if isinstance(mode, str) else False)
1503
1557
  return real_io_open(file, *args, **kwargs)
1504
1558
 
1559
+ def os_opened(path, *args, **kwargs):
1560
+ flags = kwargs['flags'] if 'flags' in kwargs else (args[0] if args else 0)
1561
+ try:
1562
+ writing = bool(flags & write_flags)
1563
+ except TypeError:
1564
+ writing = False
1565
+ ready(path, writing)
1566
+ return real_os_open(path, *args, **kwargs)
1567
+
1505
1568
  def names(path):
1506
1569
  try:
1507
1570
  text = os.path.abspath(os.fspath(path))
@@ -1534,12 +1597,37 @@ def _install():
1534
1597
 
1535
1598
  builtins.open = opened
1536
1599
  io.open = io_opened
1600
+ os.open = os_opened
1537
1601
  os.listdir = listed
1538
1602
  os.stat = stated
1539
1603
  os.scandir = real_scandir
1540
1604
 
1541
1605
 
1606
+ # letify's directory goes first on PYTHONPATH, so it shadows a sitecustomize the user
1607
+ # installed. That one still has to run.
1608
+ def _chain():
1609
+ import importlib.util
1610
+
1611
+ here = os.path.dirname(os.path.abspath(__file__))
1612
+ for entry in sys.path:
1613
+ if not entry:
1614
+ continue
1615
+ try:
1616
+ if os.path.abspath(entry) == here:
1617
+ continue
1618
+ candidate = os.path.join(entry, 'sitecustomize.py')
1619
+ if not os.path.isfile(candidate):
1620
+ continue
1621
+ spec = importlib.util.spec_from_file_location('letify_other_sitecustomize', candidate)
1622
+ module = importlib.util.module_from_spec(spec)
1623
+ spec.loader.exec_module(module)
1624
+ except Exception:
1625
+ pass
1626
+ return
1627
+
1628
+
1542
1629
  _install()
1630
+ _chain()
1543
1631
  """
1544
1632
 
1545
1633
 
@@ -2126,12 +2214,18 @@ def _run(stream, request, settle):
2126
2214
  request = None
2127
2215
  if settle:
2128
2216
  _settle()
2129
- if name == "call":
2130
- # Spec "Worker output": the body's output is framed before its result goes out.
2217
+ if name in _DRAINS:
2218
+ # Spec "Worker output": what the program wrote is framed before its result goes
2219
+ # out. Every op that runs a program drains; a stat or a lease renewal does not,
2220
+ # because they write nothing and are on the hot path.
2131
2221
  _drain_output()
2132
2222
  _reply(stream, outcome)
2133
2223
 
2134
2224
 
2225
+ #: Ops that run a program, so output of theirs may still be unframed when they answer.
2226
+ #: Spec "Worker output".
2227
+ _DRAINS = frozenset(("call", "exec"))
2228
+
2135
2229
  _DATA_PREFIX = b"LETIFY-DATA "
2136
2230
 
2137
2231
 
@@ -374,6 +374,18 @@ class Provider(abc.ABC):
374
374
  """
375
375
  return []
376
376
 
377
+ def orphans(self) -> list[str]:
378
+ """What a dead process left behind on this account, for a provider that can tell.
379
+
380
+ Spec "Kaggle", stopping what a dead process left behind. Empty for a provider that
381
+ cannot, so `letify stop --orphans` has nothing to delete there.
382
+ """
383
+ return []
384
+
385
+ def stop_orphan(self, ref: str) -> bool:
386
+ """Delete one thing `orphans` reported, and report whether it is gone."""
387
+ return False
388
+
377
389
  def expected_cards(self, instance: Any) -> tuple[str, int] | None:
378
390
  """The card name fragment and count a session for ``instance`` must have, or None.
379
391