letify 2.4.2__tar.gz → 2.4.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.

Potentially problematic release.


This version of letify might be problematic. Click here for more details.

Files changed (202) hide show
  1. {letify-2.4.2 → letify-2.4.4}/PKG-INFO +1 -1
  2. {letify-2.4.2 → letify-2.4.4}/letify/__init__.py +7 -1
  3. {letify-2.4.2 → letify-2.4.4}/letify/declare/function.py +52 -3
  4. {letify-2.4.2 → letify-2.4.4}/letify/declare/instance.py +3 -0
  5. letify-2.4.4/letify/declare/replicas.py +111 -0
  6. {letify-2.4.2 → letify-2.4.4}/letify/protocol/worker.py +60 -7
  7. {letify-2.4.2 → letify-2.4.4}/letify/store/pathdata.py +40 -1
  8. {letify-2.4.2 → letify-2.4.4}/letify-core/Cargo.lock +3 -3
  9. {letify-2.4.2 → letify-2.4.4}/letify-core/Cargo.toml +1 -1
  10. {letify-2.4.2 → letify-2.4.4}/letify-ext/package-lock.json +2 -2
  11. {letify-2.4.2 → letify-2.4.4}/letify-ext/package.json +1 -1
  12. {letify-2.4.2 → letify-2.4.4}/pyproject.toml +1 -1
  13. {letify-2.4.2 → letify-2.4.4}/tests/test_data_streaming.py +186 -0
  14. letify-2.4.4/tests/test_distributed.py +149 -0
  15. {letify-2.4.2 → letify-2.4.4}/.github/workflows/ci.yml +0 -0
  16. {letify-2.4.2 → letify-2.4.4}/.github/workflows/docs.yml +0 -0
  17. {letify-2.4.2 → letify-2.4.4}/.github/workflows/publish.yml +0 -0
  18. {letify-2.4.2 → letify-2.4.4}/.gitignore +0 -0
  19. {letify-2.4.2 → letify-2.4.4}/.researchtree +0 -0
  20. {letify-2.4.2 → letify-2.4.4}/LICENSE +0 -0
  21. {letify-2.4.2 → letify-2.4.4}/README.md +0 -0
  22. {letify-2.4.2 → letify-2.4.4}/docs/guide/01-getting-started.md +0 -0
  23. {letify-2.4.2 → letify-2.4.4}/docs/guide/02-providers.md +0 -0
  24. {letify-2.4.2 → letify-2.4.4}/docs/guide/03-execution-modes.md +0 -0
  25. {letify-2.4.2 → letify-2.4.4}/docs/guide/04-environments-and-data.md +0 -0
  26. {letify-2.4.2 → letify-2.4.4}/docs/guide/05-concurrency.md +0 -0
  27. {letify-2.4.2 → letify-2.4.4}/docs/guide/06-cost.md +0 -0
  28. {letify-2.4.2 → letify-2.4.4}/docs/guide/07-troubleshooting.md +0 -0
  29. {letify-2.4.2 → letify-2.4.4}/docs/guide/08-releasing.md +0 -0
  30. {letify-2.4.2 → letify-2.4.4}/docs/guide/README.md +0 -0
  31. {letify-2.4.2 → letify-2.4.4}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  32. {letify-2.4.2 → letify-2.4.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-2.4.2 → letify-2.4.4}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  34. {letify-2.4.2 → letify-2.4.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-2.4.2 → letify-2.4.4}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  36. {letify-2.4.2 → letify-2.4.4}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  37. {letify-2.4.2 → letify-2.4.4}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  38. {letify-2.4.2 → letify-2.4.4}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  39. {letify-2.4.2 → letify-2.4.4}/docs/guide/ko/README.md +0 -0
  40. {letify-2.4.2 → letify-2.4.4}/docs/locales/README_ko.md +0 -0
  41. {letify-2.4.2 → letify-2.4.4}/docs/mkdocs.yml +0 -0
  42. {letify-2.4.2 → letify-2.4.4}/examples/README.md +0 -0
  43. {letify-2.4.2 → letify-2.4.4}/examples/nvfp4-lora/.letify.example +0 -0
  44. {letify-2.4.2 → letify-2.4.4}/examples/nvfp4-lora/00_smoke.py +0 -0
  45. {letify-2.4.2 → letify-2.4.4}/examples/nvfp4-lora/01_sweep.py +0 -0
  46. {letify-2.4.2 → letify-2.4.4}/examples/nvfp4-lora/02_resume.py +0 -0
  47. {letify-2.4.2 → letify-2.4.4}/examples/nvfp4-lora/03_watch.py +0 -0
  48. {letify-2.4.2 → letify-2.4.4}/examples/nvfp4-lora/README.md +0 -0
  49. {letify-2.4.2 → letify-2.4.4}/examples/nvfp4-lora/README_ko.md +0 -0
  50. {letify-2.4.2 → letify-2.4.4}/examples/nvfp4-lora/common.py +0 -0
  51. {letify-2.4.2 → letify-2.4.4}/examples/nvfp4-lora/recipe.py +0 -0
  52. {letify-2.4.2 → letify-2.4.4}/letify/cli.py +0 -0
  53. {letify-2.4.2 → letify-2.4.4}/letify/config/__init__.py +0 -0
  54. {letify-2.4.2 → letify-2.4.4}/letify/config/inventory.py +0 -0
  55. {letify-2.4.2 → letify-2.4.4}/letify/config/login.py +0 -0
  56. {letify-2.4.2 → letify-2.4.4}/letify/config/schema.py +0 -0
  57. {letify-2.4.2 → letify-2.4.4}/letify/config/secrets.py +0 -0
  58. {letify-2.4.2 → letify-2.4.4}/letify/config/writer.py +0 -0
  59. {letify-2.4.2 → letify-2.4.4}/letify/declare/__init__.py +0 -0
  60. {letify-2.4.2 → letify-2.4.4}/letify/declare/cache.py +0 -0
  61. {letify-2.4.2 → letify-2.4.4}/letify/declare/env.py +0 -0
  62. {letify-2.4.2 → letify-2.4.4}/letify/errors.py +0 -0
  63. {letify-2.4.2 → letify-2.4.4}/letify/install.py +0 -0
  64. {letify-2.4.2 → letify-2.4.4}/letify/launcher.py +0 -0
  65. {letify-2.4.2 → letify-2.4.4}/letify/paths.py +0 -0
  66. {letify-2.4.2 → letify-2.4.4}/letify/protocol/__init__.py +0 -0
  67. {letify-2.4.2 → letify-2.4.4}/letify/protocol/codec.py +0 -0
  68. {letify-2.4.2 → letify-2.4.4}/letify/protocol/driver.py +0 -0
  69. {letify-2.4.2 → letify-2.4.4}/letify/protocol/handle.py +0 -0
  70. {letify-2.4.2 → letify-2.4.4}/letify/protocol/wire.py +0 -0
  71. {letify-2.4.2 → letify-2.4.4}/letify/providers/__init__.py +0 -0
  72. {letify-2.4.2 → letify-2.4.4}/letify/providers/base.py +0 -0
  73. {letify-2.4.2 → letify-2.4.4}/letify/providers/colab.py +0 -0
  74. {letify-2.4.2 → letify-2.4.4}/letify/providers/colab_files.py +0 -0
  75. {letify-2.4.2 → letify-2.4.4}/letify/providers/colab_keepalive.py +0 -0
  76. {letify-2.4.2 → letify-2.4.4}/letify/providers/elice.py +0 -0
  77. {letify-2.4.2 → letify-2.4.4}/letify/providers/kaggle.py +0 -0
  78. {letify-2.4.2 → letify-2.4.4}/letify/providers/kaggle_adapter.py +0 -0
  79. {letify-2.4.2 → letify-2.4.4}/letify/providers/local.py +0 -0
  80. {letify-2.4.2 → letify-2.4.4}/letify/providers/modal.py +0 -0
  81. {letify-2.4.2 → letify-2.4.4}/letify/providers/modal_adapter.py +0 -0
  82. {letify-2.4.2 → letify-2.4.4}/letify/providers/naming.py +0 -0
  83. {letify-2.4.2 → letify-2.4.4}/letify/providers/shell.py +0 -0
  84. {letify-2.4.2 → letify-2.4.4}/letify/providers/tunnel.py +0 -0
  85. {letify-2.4.2 → letify-2.4.4}/letify/providers/usage.py +0 -0
  86. {letify-2.4.2 → letify-2.4.4}/letify/remoting/__init__.py +0 -0
  87. {letify-2.4.2 → letify-2.4.4}/letify/remoting/capability.py +0 -0
  88. {letify-2.4.2 → letify-2.4.4}/letify/remoting/device/__init__.py +0 -0
  89. {letify-2.4.2 → letify-2.4.4}/letify/remoting/device/client.py +0 -0
  90. {letify-2.4.2 → letify-2.4.4}/letify/remoting/device/cuda.py +0 -0
  91. {letify-2.4.2 → letify-2.4.4}/letify/remoting/device/executor.py +0 -0
  92. {letify-2.4.2 → letify-2.4.4}/letify/remoting/device/frames.py +0 -0
  93. {letify-2.4.2 → letify-2.4.4}/letify/remoting/device/guard.py +0 -0
  94. {letify-2.4.2 → letify-2.4.4}/letify/remoting/device/tensor.py +0 -0
  95. {letify-2.4.2 → letify-2.4.4}/letify/remoting/device/trace.py +0 -0
  96. {letify-2.4.2 → letify-2.4.4}/letify/remoting/device/value.py +0 -0
  97. {letify-2.4.2 → letify-2.4.4}/letify/remoting/loader.py +0 -0
  98. {letify-2.4.2 → letify-2.4.4}/letify/remoting/probe.py +0 -0
  99. {letify-2.4.2 → letify-2.4.4}/letify/render.py +0 -0
  100. {letify-2.4.2 → letify-2.4.4}/letify/runtime/__init__.py +0 -0
  101. {letify-2.4.2 → letify-2.4.4}/letify/runtime/bootstrap.py +0 -0
  102. {letify-2.4.2 → letify-2.4.4}/letify/runtime/channel.py +0 -0
  103. {letify-2.4.2 → letify-2.4.4}/letify/runtime/lease.py +0 -0
  104. {letify-2.4.2 → letify-2.4.4}/letify/runtime/pool.py +0 -0
  105. {letify-2.4.2 → letify-2.4.4}/letify/runtime/session.py +0 -0
  106. {letify-2.4.2 → letify-2.4.4}/letify/runtime/telemetry.py +0 -0
  107. {letify-2.4.2 → letify-2.4.4}/letify/store/__init__.py +0 -0
  108. {letify-2.4.2 → letify-2.4.4}/letify/store/backends/__init__.py +0 -0
  109. {letify-2.4.2 → letify-2.4.4}/letify/store/backends/filesystem.py +0 -0
  110. {letify-2.4.2 → letify-2.4.4}/letify/store/backends/google_auth.py +0 -0
  111. {letify-2.4.2 → letify-2.4.4}/letify/store/backends/layout.py +0 -0
  112. {letify-2.4.2 → letify-2.4.4}/letify/store/backends/objects.py +0 -0
  113. {letify-2.4.2 → letify-2.4.4}/letify/store/cas.py +0 -0
  114. {letify-2.4.2 → letify-2.4.4}/letify/store/sendorder.py +0 -0
  115. {letify-2.4.2 → letify-2.4.4}/letify/store/volume.py +0 -0
  116. {letify-2.4.2 → letify-2.4.4}/letify/stubs.py +0 -0
  117. {letify-2.4.2 → letify-2.4.4}/letify/tools.py +0 -0
  118. {letify-2.4.2 → letify-2.4.4}/letify/transport/__init__.py +0 -0
  119. {letify-2.4.2 → letify-2.4.4}/letify/transport/agent.py +0 -0
  120. {letify-2.4.2 → letify-2.4.4}/letify/transport/announce.py +0 -0
  121. {letify-2.4.2 → letify-2.4.4}/letify/transport/link.py +0 -0
  122. {letify-2.4.2 → letify-2.4.4}/letify/transport/nat.py +0 -0
  123. {letify-2.4.2 → letify-2.4.4}/letify/transport/pipeline.py +0 -0
  124. {letify-2.4.2 → letify-2.4.4}/letify/transport/portmap.py +0 -0
  125. {letify-2.4.2 → letify-2.4.4}/letify/transport/probe.py +0 -0
  126. {letify-2.4.2 → letify-2.4.4}/letify/transport/quic.py +0 -0
  127. {letify-2.4.2 → letify-2.4.4}/letify/transport/rendezvous.py +0 -0
  128. {letify-2.4.2 → letify-2.4.4}/letify/transport/setup.py +0 -0
  129. {letify-2.4.2 → letify-2.4.4}/letify/transport/sshopts.py +0 -0
  130. {letify-2.4.2 → letify-2.4.4}/letify/transport/strategies.py +0 -0
  131. {letify-2.4.2 → letify-2.4.4}/letify-core/.gitignore +0 -0
  132. {letify-2.4.2 → letify-2.4.4}/letify-core/build.py +0 -0
  133. {letify-2.4.2 → letify-2.4.4}/letify-core/letify-agent/Cargo.toml +0 -0
  134. {letify-2.4.2 → letify-2.4.4}/letify-core/letify-agent/src/driver.rs +0 -0
  135. {letify-2.4.2 → letify-2.4.4}/letify-core/letify-agent/src/main.rs +0 -0
  136. {letify-2.4.2 → letify-2.4.4}/letify-core/letify-driver/Cargo.toml +0 -0
  137. {letify-2.4.2 → letify-2.4.4}/letify-core/letify-driver/src/client.rs +0 -0
  138. {letify-2.4.2 → letify-2.4.4}/letify-core/letify-driver/src/exports.rs +0 -0
  139. {letify-2.4.2 → letify-2.4.4}/letify-core/letify-driver/src/image.rs +0 -0
  140. {letify-2.4.2 → letify-2.4.4}/letify-core/letify-driver/src/lib.rs +0 -0
  141. {letify-2.4.2 → letify-2.4.4}/letify-core/letify-driver/src/status.rs +0 -0
  142. {letify-2.4.2 → letify-2.4.4}/letify-core/letify-driver/src/table.rs +0 -0
  143. {letify-2.4.2 → letify-2.4.4}/letify-core/letify-quic/Cargo.toml +0 -0
  144. {letify-2.4.2 → letify-2.4.4}/letify-core/letify-quic/src/main.rs +0 -0
  145. {letify-2.4.2 → letify-2.4.4}/letify-core/letify-wire/Cargo.toml +0 -0
  146. {letify-2.4.2 → letify-2.4.4}/letify-core/letify-wire/src/lib.rs +0 -0
  147. {letify-2.4.2 → letify-2.4.4}/letify-core/letify-wire/tests/throughput.rs +0 -0
  148. {letify-2.4.2 → letify-2.4.4}/letify-core/rust-toolchain.toml +0 -0
  149. {letify-2.4.2 → letify-2.4.4}/letify-ext/.vscodeignore +0 -0
  150. {letify-2.4.2 → letify-2.4.4}/letify-ext/LICENSE +0 -0
  151. {letify-2.4.2 → letify-2.4.4}/letify-ext/README.md +0 -0
  152. {letify-2.4.2 → letify-2.4.4}/letify-ext/media/icon.svg +0 -0
  153. {letify-2.4.2 → letify-2.4.4}/letify-ext/src/cli.ts +0 -0
  154. {letify-2.4.2 → letify-2.4.4}/letify-ext/src/extension.ts +0 -0
  155. {letify-2.4.2 → letify-2.4.4}/letify-ext/src/history.ts +0 -0
  156. {letify-2.4.2 → letify-2.4.4}/letify-ext/src/model.ts +0 -0
  157. {letify-2.4.2 → letify-2.4.4}/letify-ext/src/poll.ts +0 -0
  158. {letify-2.4.2 → letify-2.4.4}/letify-ext/src/render.ts +0 -0
  159. {letify-2.4.2 → letify-2.4.4}/letify-ext/test/cli.test.ts +0 -0
  160. {letify-2.4.2 → letify-2.4.4}/letify-ext/test/manifest.test.ts +0 -0
  161. {letify-2.4.2 → letify-2.4.4}/letify-ext/test/model.test.ts +0 -0
  162. {letify-2.4.2 → letify-2.4.4}/letify-ext/test/regressions.test.ts +0 -0
  163. {letify-2.4.2 → letify-2.4.4}/letify-ext/tsconfig.json +0 -0
  164. {letify-2.4.2 → letify-2.4.4}/letify_providers.py +0 -0
  165. {letify-2.4.2 → letify-2.4.4}/scripts/sync-main.sh +0 -0
  166. {letify-2.4.2 → letify-2.4.4}/scripts/version.py +0 -0
  167. {letify-2.4.2 → letify-2.4.4}/tests/conftest.py +0 -0
  168. {letify-2.4.2 → letify-2.4.4}/tests/fake_kaggle_adapter.py +0 -0
  169. {letify-2.4.2 → letify-2.4.4}/tests/fake_modal_adapter.py +0 -0
  170. {letify-2.4.2 → letify-2.4.4}/tests/test_cli.py +0 -0
  171. {letify-2.4.2 → letify-2.4.4}/tests/test_cli_output.py +0 -0
  172. {letify-2.4.2 → letify-2.4.4}/tests/test_colab_keepalive.py +0 -0
  173. {letify-2.4.2 → letify-2.4.4}/tests/test_config.py +0 -0
  174. {letify-2.4.2 → letify-2.4.4}/tests/test_core.py +0 -0
  175. {letify-2.4.2 → letify-2.4.4}/tests/test_data.py +0 -0
  176. {letify-2.4.2 → letify-2.4.4}/tests/test_declare.py +0 -0
  177. {letify-2.4.2 → letify-2.4.4}/tests/test_device.py +0 -0
  178. {letify-2.4.2 → letify-2.4.4}/tests/test_editor_sessions.py +0 -0
  179. {letify-2.4.2 → letify-2.4.4}/tests/test_elice.py +0 -0
  180. {letify-2.4.2 → letify-2.4.4}/tests/test_frames.py +0 -0
  181. {letify-2.4.2 → letify-2.4.4}/tests/test_install.py +0 -0
  182. {letify-2.4.2 → letify-2.4.4}/tests/test_kaggle.py +0 -0
  183. {letify-2.4.2 → letify-2.4.4}/tests/test_kaggle_cookie.py +0 -0
  184. {letify-2.4.2 → letify-2.4.4}/tests/test_kaggle_link.py +0 -0
  185. {letify-2.4.2 → letify-2.4.4}/tests/test_link_log.py +0 -0
  186. {letify-2.4.2 → letify-2.4.4}/tests/test_notebook.py +0 -0
  187. {letify-2.4.2 → letify-2.4.4}/tests/test_parallel_placement.py +0 -0
  188. {letify-2.4.2 → letify-2.4.4}/tests/test_paths.py +0 -0
  189. {letify-2.4.2 → letify-2.4.4}/tests/test_portmap.py +0 -0
  190. {letify-2.4.2 → letify-2.4.4}/tests/test_protocol.py +0 -0
  191. {letify-2.4.2 → letify-2.4.4}/tests/test_providers.py +0 -0
  192. {letify-2.4.2 → letify-2.4.4}/tests/test_quic.py +0 -0
  193. {letify-2.4.2 → letify-2.4.4}/tests/test_remoting.py +0 -0
  194. {letify-2.4.2 → letify-2.4.4}/tests/test_render.py +0 -0
  195. {letify-2.4.2 → letify-2.4.4}/tests/test_runtime.py +0 -0
  196. {letify-2.4.2 → letify-2.4.4}/tests/test_ship.py +0 -0
  197. {letify-2.4.2 → letify-2.4.4}/tests/test_store.py +0 -0
  198. {letify-2.4.2 → letify-2.4.4}/tests/test_strategies.py +0 -0
  199. {letify-2.4.2 → letify-2.4.4}/tests/test_stubs.py +0 -0
  200. {letify-2.4.2 → letify-2.4.4}/tests/test_transport.py +0 -0
  201. {letify-2.4.2 → letify-2.4.4}/tests/test_tunnel_setup.py +0 -0
  202. {letify-2.4.2 → letify-2.4.4}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.4.2
3
+ Version: 2.4.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
@@ -33,6 +33,7 @@ from .declare.cache import session_cache
33
33
  from .declare.env import Env
34
34
  from .declare.function import Function
35
35
  from .declare.instance import AnyInstance, Host, Instance
36
+ from .declare.replicas import local_steps, rank, world_size
36
37
  from .errors import (
37
38
  ConfigError,
38
39
  EnvironmentFailure,
@@ -58,11 +59,12 @@ from .store.volume import Volume
58
59
  #: ``Final`` so a type checker sees the literal member, which the declaration overloads match on.
59
60
  local: Final = Host.local
60
61
  remote: Final = Host.remote
62
+ distributed: Final = Host.distributed
61
63
 
62
64
  # The enum class stays importable from letify.declare.instance for letify's own use.
63
65
  del Host
64
66
 
65
- __version__ = "2.4.2"
67
+ __version__ = "2.4.4"
66
68
 
67
69
 
68
70
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -101,7 +103,11 @@ __all__ = [
101
103
  "UnsupportedMode",
102
104
  "Volume",
103
105
  "__version__",
106
+ "distributed",
104
107
  "local",
108
+ "local_steps",
109
+ "rank",
105
110
  "remote",
106
111
  "session_cache",
112
+ "world_size",
107
113
  ]
@@ -65,7 +65,9 @@ class Function(Generic[R]):
65
65
  self.data_order = data_order
66
66
  self.data_first_wave = data_first_wave
67
67
  self.is_async = inspect.iscoroutinefunction(fn)
68
- self.device = self._place(device)
68
+ #: Spec "Replicas": one Instance per replica, in the order the list named them.
69
+ self.replicas = _replicas(device, self.host)
70
+ self.device = self._place(self.replicas[0] if self.replicas else device)
69
71
  update_wrapper(self, fn)
70
72
 
71
73
  def _place(self, device: Instance | AnyInstance) -> Instance | AnyInstance:
@@ -136,6 +138,16 @@ class Function(Generic[R]):
136
138
 
137
139
  def _run(self, args: tuple, kwargs: dict) -> Any:
138
140
  launcher = self.launcher
141
+ if self.host is Host.distributed:
142
+ # Spec "Replicas": the placement is declared and the runner is not written
143
+ # yet. Running the first replica alone would bill one runtime for a
144
+ # declaration that asked for several, without saying so.
145
+ raise UnsupportedMode(
146
+ f"host=letify.distributed declares {len(self.replicas)} replicas and "
147
+ f"letify cannot run them yet, so the call is refused rather than run on "
148
+ f"one of them. Use host=letify.remote with a single device until it is "
149
+ f"finished."
150
+ )
139
151
  instance = launcher.resolve(self.device)
140
152
  if instance.placement is Host.local:
141
153
  return self._run_here(instance, args, kwargs)
@@ -249,6 +261,42 @@ def _report_deferred(delta: Any) -> None:
249
261
  )
250
262
 
251
263
 
264
+ def _replicas(device: Any, host: Host) -> tuple[Instance, ...]:
265
+ """The replicas a declaration names, or empty when it names a single device.
266
+
267
+ Spec "Replicas": ``device`` takes a list only under ``host=letify.distributed``, each
268
+ entry is one replica, and two replicas of one instance are refused because that asks
269
+ one account for twice the devices it has.
270
+ """
271
+ listed = isinstance(device, (list, tuple))
272
+ if listed and host is not Host.distributed:
273
+ raise ValueError(
274
+ f"device is a list of {len(device)} instances, which declares one replica "
275
+ f"each, so it needs host=letify.distributed. Under host={host.value!r} name "
276
+ f"one device."
277
+ )
278
+ if not listed:
279
+ return (device,) if host is Host.distributed else ()
280
+ if not device:
281
+ raise ValueError(
282
+ "device is an empty list, so the declaration names no replica. Name at least "
283
+ "one instance."
284
+ )
285
+ seen: list[str] = []
286
+ for instance in device:
287
+ alias = getattr(getattr(instance, "provider", None), "alias", None)
288
+ name = getattr(instance, "accelerator", None)
289
+ key = f"{alias}.{name}"
290
+ if key in seen:
291
+ raise ValueError(
292
+ f"device names {key} twice, so one account would be asked for twice the "
293
+ f"devices one replica takes. Name each account once, and use a larger "
294
+ f"instance such as {name} * 2 for more devices in one replica."
295
+ )
296
+ seen.append(key)
297
+ return tuple(device)
298
+
299
+
252
300
  def _where(host: Host | str | None) -> Host:
253
301
  """Validate the declared host placement, naming both options when it is wrong."""
254
302
  if host is None:
@@ -258,8 +306,9 @@ def _where(host: Host | str | None) -> Host:
258
306
  except ValueError:
259
307
  raise ValueError(
260
308
  f"host={host!r} is not a host placement. Use host='local' to keep Python "
261
- f"here and forward PyTorch operators, or host='remote' to ship the function to "
262
- f"the machine that holds the device."
309
+ f"here and forward PyTorch operators, host='remote' to ship the function to "
310
+ f"the machine that holds the device, or host='distributed' to run it once per "
311
+ f"device in a list."
263
312
  ) from None
264
313
 
265
314
 
@@ -39,6 +39,9 @@ class Host(StrEnum):
39
39
 
40
40
  local = "local"
41
41
  remote = "remote"
42
+ #: The same body on every device of a ``device`` list, each replica knowing its rank.
43
+ #: Spec "Replicas".
44
+ distributed = "distributed"
42
45
 
43
46
 
44
47
  @dataclass(frozen=True, slots=True)
@@ -0,0 +1,111 @@
1
+ """Where a replica learns which one it is, and what it reduces with.
2
+
3
+ Owns the per-replica facts a distributed body reads, ``rank`` and ``world_size``, and the
4
+ reduction a body asks for. It does not own the loop; spec "Replicas" says so and
5
+ ``docs/INTENT.md`` keeps that as a non-goal for every loop but one.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import threading
12
+ from typing import Any
13
+
14
+ from ..errors import UnsupportedMode
15
+
16
+ #: Set on the thread running a distributed body, so ``rank`` and ``world_size`` answer.
17
+ _WHO = threading.local()
18
+
19
+ _OUTSIDE = (
20
+ "{name}() answers only inside a call declared with host=letify.distributed. A body "
21
+ "under host=letify.remote has one replica and no rank, and answering 0 and 1 here "
22
+ "would hide that it was asked"
23
+ )
24
+
25
+
26
+ def rank() -> int:
27
+ """Which replica this is, 0 upward in the order the ``device`` list names.
28
+
29
+ Spec "Replicas": raises outside a distributed call rather than answering 0.
30
+ """
31
+ found = getattr(_WHO, "rank", None)
32
+ if found is None:
33
+ raise UnsupportedMode(_OUTSIDE.format(name="rank"))
34
+ return int(found)
35
+
36
+
37
+ def world_size() -> int:
38
+ """How many replicas started. Spec "Replicas"."""
39
+ found = getattr(_WHO, "world_size", None)
40
+ if found is None:
41
+ raise UnsupportedMode(_OUTSIDE.format(name="world_size"))
42
+ return int(found)
43
+
44
+
45
+ #: The fraction of a step a reduction may take before it is worth doing less often.
46
+ DEFAULT_SHARE = 0.25
47
+
48
+
49
+ def steps_for(
50
+ *,
51
+ param_bytes: int,
52
+ step_seconds: float,
53
+ rate_bps: float | None,
54
+ share: float = DEFAULT_SHARE,
55
+ ) -> int:
56
+ """The smallest number of local steps for which a reduction is cheap enough.
57
+
58
+ Spec "How often to synchronize": a reduction moves the parameters once, so it is
59
+ worth it only when rare enough against the step it interrupts. Raises rather than
60
+ guessing when the link reports no rate, because a guess here is a guess about the
61
+ whole run's shape.
62
+ """
63
+ if not rate_bps or rate_bps <= 0:
64
+ raise UnsupportedMode(
65
+ "local_steps needs the link's measured upload rate and this link reports "
66
+ "none, so there is nothing to divide the parameters by. The rate comes from "
67
+ "the probe, so a link the pipeline never probed, such as a provider "
68
+ "fallback, cannot answer. Pass the count instead of letify.auto"
69
+ )
70
+ if step_seconds <= 0:
71
+ raise ValueError("step_seconds has to be above zero to divide a transfer against")
72
+ allowed = max(share, 0.0) * step_seconds
73
+ if allowed <= 0:
74
+ raise ValueError("share has to be above zero, or no number of steps is ever enough")
75
+ transfer = param_bytes / rate_bps
76
+ return max(1, math.ceil(transfer / allowed))
77
+
78
+
79
+ def local_steps(param_bytes: int, step_seconds: float, share: float = DEFAULT_SHARE) -> int:
80
+ """How many steps to take between reductions, from the link letify already measured.
81
+
82
+ Spec "How often to synchronize". Answers for the replica this body runs on, so two
83
+ replicas on links of different speed get different answers.
84
+ """
85
+ rate = getattr(_WHO, "rate_bps", None)
86
+ return steps_for(
87
+ param_bytes=param_bytes,
88
+ step_seconds=step_seconds,
89
+ rate_bps=rate,
90
+ share=share,
91
+ )
92
+
93
+
94
+ class _Replica:
95
+ """Holds the rank for the body's thread, so nothing has to be passed through it."""
96
+
97
+ def __init__(self, rank: int, world_size: int, rate_bps: float | None = None) -> None:
98
+ self.rank = rank
99
+ self.world_size = world_size
100
+ self.rate_bps = rate_bps
101
+
102
+ def __enter__(self) -> _Replica:
103
+ _WHO.rank, _WHO.world_size = self.rank, self.world_size
104
+ _WHO.rate_bps = self.rate_bps
105
+ return self
106
+
107
+ def __exit__(self, *_exc: Any) -> None:
108
+ _WHO.rank = _WHO.world_size = _WHO.rate_bps = None
109
+
110
+
111
+ __all__ = ["local_steps", "rank", "steps_for", "world_size"]
@@ -365,6 +365,19 @@ def _data_hash_file(path):
365
365
  return hasher.hexdigest(length=16)
366
366
 
367
367
 
368
+ def _data_same_bytes(path, digest):
369
+ """Whether the file still hashes to the digest it is the blob of.
370
+
371
+ Spec "Runtime data cache budget": only the ambiguous case is hashed, a size that
372
+ matches with a new timestamp, so the read is paid where a timestamp cannot decide.
373
+ Never raises; a file that cannot be read is not the file the digest promised.
374
+ """
375
+ try:
376
+ return _data_hash_file(path) == digest
377
+ except OSError:
378
+ return False
379
+
380
+
368
381
  def _data_collect(data, placed):
369
382
  """Commit each file a returned call created or changed at an output into the cache."""
370
383
  import stat
@@ -431,11 +444,25 @@ def _data_check_cache(data, placed):
431
444
  info = os.stat(source)
432
445
  except OSError:
433
446
  continue
434
- if (info.st_size, info.st_mtime_ns) != cached:
435
- try:
436
- os.remove(source)
437
- except OSError:
438
- pass
447
+ if (info.st_size, info.st_mtime_ns) == cached:
448
+ continue
449
+ if info.st_size == cached[0] and _data_same_bytes(source, digest):
450
+ # Spec "Runtime data cache budget": the same bytes with a new timestamp, which
451
+ # is what a file copied back looks like. The digest still holds, so it stays.
452
+ continue
453
+ # Spec "Runtime data cache budget": removing a file a call may still need is worth
454
+ # a line, or it vanishes and only the next failure mentions it.
455
+ print(
456
+ "letify: data cache dropped %s, written through its link (%d bytes at %d, "
457
+ "placed as %d at %d)"
458
+ % (digest, info.st_size, info.st_mtime_ns, cached[0], cached[1]),
459
+ file=sys.stderr,
460
+ flush=True,
461
+ )
462
+ try:
463
+ os.remove(source)
464
+ except OSError:
465
+ pass
439
466
 
440
467
 
441
468
  def _op_data_written(request):
@@ -675,12 +702,38 @@ def _data_budget(root, total, budget):
675
702
  return min(_DATA_DEFAULT_BUDGET, (total + free) // 2)
676
703
 
677
704
 
705
+ def _digest_of(path):
706
+ """The digest a blob path holds, which is its shard directory and its name joined."""
707
+ return os.path.basename(path)
708
+
709
+
710
+ def _data_digests_in_flight():
711
+ """Every digest a running call lists, so the prune does not take one out from under it.
712
+
713
+ Spec "Runtime data cache budget". Read under the lock the data thread writes the maps
714
+ with, so a call registering while this reads cannot change one mid iteration.
715
+ """
716
+ wanted = set()
717
+ with _DATA_READY:
718
+ for state in list(_DATA_CALLS.values()):
719
+ wanted.update(state["by_digest"])
720
+ return wanted
721
+
722
+
678
723
  def _op_data_evict(request):
679
- """Remove least recently used blobs no call links until the cache is within budget."""
724
+ """Remove least recently used blobs no call links until the cache is within budget.
725
+
726
+ Spec "Runtime data cache budget": a digest a running call lists is skipped whatever
727
+ its link count and age, because the call was told the runtime held it and so did not
728
+ send it. ``recent`` is the window a blob no call is using is protected for, carried on
729
+ the request beside the budget.
730
+ """
680
731
  started = time.monotonic()
681
732
  root = os.path.expanduser(request["dir"])
682
733
  files, total = _data_scan(root)
683
734
  budget = _data_budget(root, total, request.get("budget"))
735
+ recent = float(request.get("recent", _DATA_RECENT_S))
736
+ wanted = _data_digests_in_flight()
684
737
  removed = freed = 0
685
738
  if total > budget:
686
739
  now = time.time()
@@ -688,7 +741,7 @@ def _op_data_evict(request):
688
741
  for used, path, size, links in files:
689
742
  if total <= budget:
690
743
  break
691
- if links > 1 or now - used < _DATA_RECENT_S:
744
+ if links > 1 or now - used < recent or _digest_of(path) in wanted:
692
745
  continue
693
746
  try:
694
747
  os.remove(path)
@@ -1183,7 +1183,10 @@ def _get(
1183
1183
  """
1184
1184
  from ..protocol.codec import _hasher
1185
1185
 
1186
- target.parent.mkdir(parents=True, exist_ok=True)
1186
+ # Spec "Writing back": a stream that fails after these were created would leave them
1187
+ # empty, and a resume that picks the newest directory by name then trusts a checkpoint
1188
+ # holding nothing. Remembered so the failure can take back exactly what it made.
1189
+ made = _made_dirs(target.parent)
1187
1190
  partial = target.with_name(
1188
1191
  f".{target.name}.letify-partial.{os.getpid()}.{threading.get_ident()}"
1189
1192
  )
@@ -1210,6 +1213,7 @@ def _get(
1210
1213
  writer = threading.Thread(target=write_pieces, daemon=True)
1211
1214
  writer.start()
1212
1215
  received = 0
1216
+ landed = False
1213
1217
  try:
1214
1218
  for piece in _stream(runtime, {"op": "data_stream", "dir": blobs, "digest": digest}):
1215
1219
  view = memoryview(piece).cast("B")
@@ -1230,11 +1234,46 @@ def _get(
1230
1234
  if found != digest:
1231
1235
  raise RuntimeFailure(f"{runtime.name}: file blob {digest} arrived with digest {found}")
1232
1236
  os.replace(partial, target)
1237
+ landed = True
1233
1238
  finally:
1234
1239
  if writer.is_alive():
1235
1240
  pieces.put(None)
1236
1241
  writer.join(5)
1242
+ # Before the directories are taken back, or they would not be empty yet.
1237
1243
  partial.unlink(missing_ok=True)
1244
+ if not landed:
1245
+ _unmake_dirs(made)
1246
+
1247
+
1248
+ def _made_dirs(directory: Path) -> list[Path]:
1249
+ """Create ``directory`` and return the ones this call had to make, innermost last.
1250
+
1251
+ Spec "Writing back": only what letify made is ever taken back, so a directory the
1252
+ user already had is never touched.
1253
+ """
1254
+ missing: list[Path] = []
1255
+ probe = directory
1256
+ while not probe.exists():
1257
+ missing.append(probe)
1258
+ parent = probe.parent
1259
+ if parent == probe:
1260
+ break
1261
+ probe = parent
1262
+ directory.mkdir(parents=True, exist_ok=True)
1263
+ return list(reversed(missing))
1264
+
1265
+
1266
+ def _unmake_dirs(made: list[Path]) -> None:
1267
+ """Remove the directories ``_made_dirs`` created, innermost first, while empty.
1268
+
1269
+ Spec "Writing back". A directory that holds anything is left alone, because something
1270
+ else put it there.
1271
+ """
1272
+ for directory in reversed(made):
1273
+ try:
1274
+ directory.rmdir()
1275
+ except OSError:
1276
+ return
1238
1277
 
1239
1278
 
1240
1279
  def _worker(runtime: Runtime, payload: dict[str, Any]) -> Any:
@@ -138,7 +138,7 @@ dependencies = [
138
138
 
139
139
  [[package]]
140
140
  name = "letify-agent"
141
- version = "2.4.2"
141
+ version = "2.4.4"
142
142
  dependencies = [
143
143
  "letify-wire",
144
144
  "libloading",
@@ -146,7 +146,7 @@ dependencies = [
146
146
 
147
147
  [[package]]
148
148
  name = "letify-driver"
149
- version = "2.4.2"
149
+ version = "2.4.4"
150
150
  dependencies = [
151
151
  "letify-wire",
152
152
  ]
@@ -164,7 +164,7 @@ dependencies = [
164
164
 
165
165
  [[package]]
166
166
  name = "letify-wire"
167
- version = "2.4.2"
167
+ version = "2.4.4"
168
168
 
169
169
  [[package]]
170
170
  name = "libc"
@@ -8,7 +8,7 @@ resolver = "3"
8
8
  members = ["letify-wire", "letify-driver", "letify-agent", "letify-quic"]
9
9
 
10
10
  [workspace.package]
11
- version = "2.4.2"
11
+ version = "2.4.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": "2.4.2",
3
+ "version": "2.4.4",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "letify-status",
9
- "version": "2.4.2",
9
+ "version": "2.4.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": "2.4.2",
5
+ "version": "2.4.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 = "2.4.2"
3
+ version = "2.4.4"
4
4
  description = "Declarations that become infrastructure."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1365,3 +1365,189 @@ def test_the_patch_a_fresh_process_installs_arrives_through_sitecustomize() -> N
1365
1365
  )
1366
1366
  assert "SITECUSTOMIZE" in done.stderr, done.stderr
1367
1367
  assert "PTH" not in done.stderr, done.stderr
1368
+
1369
+
1370
+ def test_the_prune_leaves_a_live_call_its_blobs(launcher_from, project) -> None:
1371
+ """Spec "Runtime data cache budget": a blob in flight for a running call is skipped.
1372
+
1373
+ A call is told which digests the runtime holds and does not send those, so a blob
1374
+ removed between the answer and the use fails the call. A ten minute call outlived the
1375
+ 600 s recency window meant to carry it, and a second call beside it pruned 148 blobs
1376
+ at once down to the budget, including ones the first had been told it had. Requests on
1377
+ one channel may overlap, so the prune is sent here while the call is still running,
1378
+ which is the same collision without the timing.
1379
+
1380
+ This passes without the guard as well: every shape that could be built quickly leaves
1381
+ the blob with two links or a fresh use time, which the two older guards already cover.
1382
+ It pins the behaviour the spec states rather than reproducing the failure, and the
1383
+ reproduction is still open.
1384
+ """
1385
+ # Nothing in the first wave, so the call places its files while it runs and the ones
1386
+ # it has not reached yet sit in the cache with a single link, which is the only state
1387
+ # the old guards did not cover.
1388
+ let = streaming(launcher_from, data_first_wave_mib=0)
1389
+ root = dataset(project, 4, size=1 << 18)
1390
+ started = threading.Event()
1391
+
1392
+ @let.function(device=let.providers.lab.CPU, host=letify.remote)
1393
+ def reads(directory: Path, flag: str) -> int:
1394
+ import os
1395
+ import time
1396
+
1397
+ with open(flag, "w") as handle:
1398
+ handle.write("in")
1399
+ for _ in range(400):
1400
+ if not os.path.exists(flag):
1401
+ break
1402
+ time.sleep(0.02)
1403
+ total = 0
1404
+ for entry in sorted(directory.iterdir()):
1405
+ total += len(entry.read_bytes())
1406
+ return total
1407
+
1408
+ flag = project / "running.flag"
1409
+ answer: list = []
1410
+
1411
+ def call() -> None:
1412
+ answer.append(reads(root, str(flag)))
1413
+
1414
+ with let.keep_alive():
1415
+ # One call first, so every blob is in the cache before the call under test starts.
1416
+ assert reads(root, str(project / "warm.flag")) == 4 * (1 << 18)
1417
+ worker = threading.Thread(target=call, daemon=True)
1418
+ worker.start()
1419
+ for _ in range(200):
1420
+ if flag.exists():
1421
+ started.set()
1422
+ break
1423
+ time.sleep(0.02)
1424
+ assert started.is_set(), "the call never reported that it had started"
1425
+ runtime = next(
1426
+ held for bucket in let.pool._runtimes.values() for held in bucket
1427
+ )
1428
+ root = (runtime.workspace or runtime.provider.workspace_root).rstrip("/")
1429
+ # Budget zero, so the prune takes everything it is allowed to take.
1430
+ runtime.request(
1431
+ # recent 0, so only the live call's own guard can save its blobs.
1432
+ {"op": "data_evict", "dir": f"{root}/data/blobs", "budget": 1, "recent": 0},
1433
+ timeout=60,
1434
+ )
1435
+ flag.unlink()
1436
+ worker.join(60)
1437
+
1438
+ assert answer == [4 * (1 << 18)], answer
1439
+
1440
+
1441
+ def test_a_write_back_that_fails_leaves_no_empty_directory(
1442
+ launcher_from, project, monkeypatch
1443
+ ) -> None:
1444
+ """Spec "Writing back": a failed stream removes the directories it made.
1445
+
1446
+ A live session's write-back created four checkpoint directories whose files never
1447
+ arrived, because the blobs they streamed from had been pruned, and a resume picked the
1448
+ newest by name and found nothing in it. That cost about 2,300 steps.
1449
+ """
1450
+ let = streaming(launcher_from, data_first_wave_mib=1024)
1451
+ out = project / "outputs"
1452
+ out.mkdir()
1453
+
1454
+ @let.function(device=let.providers.lab.CPU, host=letify.remote)
1455
+ def writes(directory: Path) -> int:
1456
+ import os as _os
1457
+
1458
+ made = _os.path.join(str(directory), "checkpoint-2607")
1459
+ _os.makedirs(made, exist_ok=True)
1460
+ with open(_os.path.join(made, "trainer_state.json"), "wb") as handle:
1461
+ handle.write(b'{"step": 2607}')
1462
+ return 1
1463
+
1464
+ # The stream fails the way a pruned blob makes it fail.
1465
+ from letify.store import pathdata as module
1466
+
1467
+ def refuse(runtime, payload):
1468
+ raise letify.RuntimeFailure("data_stream failed: FileNotFoundError")
1469
+
1470
+ monkeypatch.setattr(module, "_stream", refuse)
1471
+ with pytest.raises(letify.LetifyError):
1472
+ writes(out)
1473
+
1474
+ assert not (out / "checkpoint-2607").exists(), sorted(
1475
+ str(p.relative_to(out)) for p in out.rglob("*")
1476
+ )
1477
+
1478
+
1479
+ def test_a_write_back_leaves_a_directory_that_was_already_there(
1480
+ launcher_from, project, monkeypatch
1481
+ ) -> None:
1482
+ """Spec: a directory that already existed, or that holds anything, is left alone."""
1483
+ let = streaming(launcher_from, data_first_wave_mib=1024)
1484
+ out = project / "outputs"
1485
+ (out / "checkpoint-2607").mkdir(parents=True)
1486
+ (out / "checkpoint-2607" / "kept.txt").write_text("mine", encoding="utf-8")
1487
+
1488
+ @let.function(device=let.providers.lab.CPU, host=letify.remote)
1489
+ def writes(directory: Path) -> int:
1490
+ import os as _os
1491
+
1492
+ made = _os.path.join(str(directory), "checkpoint-2607")
1493
+ with open(_os.path.join(made, "trainer_state.json"), "wb") as handle:
1494
+ handle.write(b'{"step": 2607}')
1495
+ return 1
1496
+
1497
+ from letify.store import pathdata as module
1498
+
1499
+ def refuse(runtime, payload):
1500
+ raise letify.RuntimeFailure("data_stream failed: FileNotFoundError")
1501
+
1502
+ monkeypatch.setattr(module, "_stream", refuse)
1503
+ with pytest.raises(letify.LetifyError):
1504
+ writes(out)
1505
+
1506
+ assert (out / "checkpoint-2607" / "kept.txt").read_text(encoding="utf-8") == "mine"
1507
+
1508
+
1509
+ def test_the_cache_says_when_it_drops_a_blob_written_through_its_link() -> None:
1510
+ """Spec "Runtime data cache budget": a dropped blob is reported, not silent.
1511
+
1512
+ A live session spent two days unable to tell this removal from the budget prune,
1513
+ because a blob vanished and only the next failure mentioned it.
1514
+ """
1515
+ from letify.protocol import worker
1516
+
1517
+ at = worker.SOURCE.index("def _data_check_cache")
1518
+ body = worker.SOURCE[at : worker.SOURCE.index("\ndef ", at + 10)]
1519
+ assert "data cache dropped" in body
1520
+ assert "written through its link" in body
1521
+ assert "sys.stderr" in body
1522
+
1523
+
1524
+ def test_a_blob_whose_bytes_are_unchanged_survives_a_new_mtime(launcher_from, project) -> None:
1525
+ """Spec "Runtime data cache budget": dropped on content, not on a timestamp.
1526
+
1527
+ A session restoring its blobs by copying them back had each copy dropped again on its
1528
+ new mtime, so a loop meant to repair the store kept emptying it.
1529
+ """
1530
+ let = streaming(launcher_from, data_first_wave_mib=1024)
1531
+ root = dataset(project, 1, size=1 << 12)
1532
+ target = root / "000.bin"
1533
+
1534
+ @let.function(device=let.providers.lab.CPU, host=letify.remote)
1535
+ def touches(path: Path) -> list[int]:
1536
+ import os as _os
1537
+
1538
+ info = _os.stat(path)
1539
+ # The same bytes, a new timestamp: what a copy back looks like.
1540
+ _os.utime(path, (info.st_atime, info.st_mtime + 120))
1541
+ return [info.st_nlink, _os.stat(path).st_size]
1542
+
1543
+ links, size = touches(target)
1544
+ assert links == 2 and size == 1 << 12
1545
+
1546
+ # The blob is still there, so a second call is told the runtime has it.
1547
+ import io
1548
+ from contextlib import redirect_stderr
1549
+
1550
+ captured = io.StringIO()
1551
+ with redirect_stderr(captured):
1552
+ assert touches(target)[1] == 1 << 12
1553
+ assert "data cache dropped" not in captured.getvalue()