letify 2.5.2__tar.gz → 2.5.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.
- {letify-2.5.2 → letify-2.5.4}/PKG-INFO +1 -1
- {letify-2.5.2 → letify-2.5.4}/docs/SPEC.md +15 -1
- {letify-2.5.2 → letify-2.5.4}/letify/__init__.py +1 -1
- {letify-2.5.2 → letify-2.5.4}/letify/cli.py +47 -2
- {letify-2.5.2 → letify-2.5.4}/letify/protocol/worker.py +79 -1
- {letify-2.5.2 → letify-2.5.4}/letify/runtime/session.py +1 -1
- {letify-2.5.2 → letify-2.5.4}/letify/store/pathdata.py +78 -10
- {letify-2.5.2 → letify-2.5.4}/letify-core/Cargo.lock +3 -3
- {letify-2.5.2 → letify-2.5.4}/letify-core/Cargo.toml +1 -1
- {letify-2.5.2 → letify-2.5.4}/letify-ext/package-lock.json +2 -2
- {letify-2.5.2 → letify-2.5.4}/letify-ext/package.json +1 -1
- {letify-2.5.2 → letify-2.5.4}/pyproject.toml +1 -1
- {letify-2.5.2 → letify-2.5.4}/tests/test_cli.py +58 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_data.py +46 -5
- {letify-2.5.2 → letify-2.5.4}/tests/test_data_streaming.py +133 -0
- {letify-2.5.2 → letify-2.5.4}/.github/workflows/ci.yml +0 -0
- {letify-2.5.2 → letify-2.5.4}/.github/workflows/docs.yml +0 -0
- {letify-2.5.2 → letify-2.5.4}/.github/workflows/publish.yml +0 -0
- {letify-2.5.2 → letify-2.5.4}/.gitignore +0 -0
- {letify-2.5.2 → letify-2.5.4}/.researchtree +0 -0
- {letify-2.5.2 → letify-2.5.4}/CLAUDE.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/LICENSE +0 -0
- {letify-2.5.2 → letify-2.5.4}/PROJECT.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/README.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/COMPONENT.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/INTENT.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/NETWORK.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/guide/01-getting-started.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/guide/02-providers.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/guide/03-execution-modes.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/guide/04-environments-and-data.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/guide/05-concurrency.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/guide/06-cost.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/guide/07-troubleshooting.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/guide/08-releasing.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/guide/README.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
- {letify-2.5.2 → letify-2.5.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
- {letify-2.5.2 → letify-2.5.4}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/guide/ko/README.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/locales/README_ko.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/docs/mkdocs.yml +0 -0
- {letify-2.5.2 → letify-2.5.4}/examples/README.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-2.5.2 → letify-2.5.4}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/examples/nvfp4-lora/README.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/examples/nvfp4-lora/common.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/config/__init__.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/config/inventory.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/config/login.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/config/schema.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/config/secrets.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/config/writer.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/declare/__init__.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/declare/cache.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/declare/env.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/declare/function.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/declare/instance.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/declare/reduce.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/declare/replicas.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/errors.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/install.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/launcher.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/paths.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/protocol/__init__.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/protocol/codec.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/protocol/driver.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/protocol/handle.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/protocol/wire.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/providers/__init__.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/providers/base.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/providers/colab.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/providers/colab_files.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/providers/colab_keepalive.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/providers/elice.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/providers/kaggle.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/providers/kaggle_adapter.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/providers/local.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/providers/modal.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/providers/modal_adapter.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/providers/naming.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/providers/shell.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/providers/tunnel.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/providers/usage.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/remoting/__init__.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/remoting/capability.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/remoting/device/__init__.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/remoting/device/client.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/remoting/device/cuda.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/remoting/device/executor.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/remoting/device/frames.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/remoting/device/guard.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/remoting/device/tensor.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/remoting/device/trace.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/remoting/device/value.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/remoting/loader.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/remoting/probe.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/render.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/runtime/__init__.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/runtime/bootstrap.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/runtime/broker.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/runtime/channel.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/runtime/lease.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/runtime/pool.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/runtime/telemetry.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/store/__init__.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/store/backends/__init__.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/store/backends/filesystem.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/store/backends/google_auth.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/store/backends/layout.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/store/backends/objects.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/store/cas.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/store/sendorder.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/store/volume.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/stubs.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/tools.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/transport/__init__.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/transport/agent.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/transport/announce.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/transport/link.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/transport/nat.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/transport/pipeline.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/transport/portmap.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/transport/probe.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/transport/quic.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/transport/rendezvous.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/transport/setup.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/transport/sshopts.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify/transport/strategies.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-core/.gitignore +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-core/build.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-core/letify-quic/Cargo.toml +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-core/letify-quic/src/main.rs +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-core/rust-toolchain.toml +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-ext/.vscodeignore +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-ext/LICENSE +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-ext/README.md +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-ext/media/icon.svg +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-ext/src/cli.ts +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-ext/src/extension.ts +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-ext/src/history.ts +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-ext/src/model.ts +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-ext/src/poll.ts +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-ext/src/render.ts +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-ext/test/cli.test.ts +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-ext/test/manifest.test.ts +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-ext/test/model.test.ts +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-ext/test/regressions.test.ts +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify-ext/tsconfig.json +0 -0
- {letify-2.5.2 → letify-2.5.4}/letify_providers.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/scripts/sync-main.sh +0 -0
- {letify-2.5.2 → letify-2.5.4}/scripts/version.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/conftest.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/fake_kaggle_adapter.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/fake_modal_adapter.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_cli_output.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_colab_keepalive.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_config.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_core.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_declare.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_device.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_distributed.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_editor_sessions.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_elice.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_frames.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_install.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_kaggle.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_kaggle_cookie.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_kaggle_link.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_link_log.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_notebook.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_parallel_placement.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_paths.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_portmap.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_protocol.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_providers.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_quic.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_remoting.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_render.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_runtime.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_ship.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_store.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_strategies.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_stubs.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_transport.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_tunnel_setup.py +0 -0
- {letify-2.5.2 → letify-2.5.4}/tests/test_version.py +0 -0
|
@@ -1098,6 +1098,15 @@ A detected path that exists is an input. A detected directory and a detected pat
|
|
|
1098
1098
|
|
|
1099
1099
|
Any other path is pickled unchanged, so the body receives the local path as it was. Only `os.PathLike` objects are detected. A string that happens to name a local file is left alone, because no rule tells a path from any other string.
|
|
1100
1100
|
|
|
1101
|
+
One other path is refused rather than pickled unchanged: an `os.PathLike` naming a file or directory that exists on this machine and lies outside every allowed root. Passing it on leaves the body reading a path that is not there, which fails as `FileNotFoundError` on the runtime and names neither the cause nor the rule, and on a runtime where something does sit at that path it reads the wrong bytes instead. So the call raises `ValueError` before it is sent:
|
|
1102
|
+
|
|
1103
|
+
```
|
|
1104
|
+
<path> is outside the roots letify may send data from. The project root is <root>. Add its
|
|
1105
|
+
directory to [tool.letify] data_roots in <root>/pyproject.toml, or move the file inside the root.
|
|
1106
|
+
```
|
|
1107
|
+
|
|
1108
|
+
A path that exists here and is inside a root is data; a path that does not exist here is a runtime path and still travels unchanged, because that is how a body is told where to write.
|
|
1109
|
+
|
|
1101
1110
|
A directory is walked without following symbolic links to directories. Regular files and symbolic links to regular files are included. Entries named `.git`, `.venv` and `__pycache__` are skipped.
|
|
1102
1111
|
|
|
1103
1112
|
#### Digests and the digest cache <!-- id: project-data-digests -->
|
|
@@ -1230,6 +1239,7 @@ A `stat` cannot take that snapshot first, because it is on a data loader's hot p
|
|
|
1230
1239
|
#### When a blob does not arrive <!-- id: project-data-streaming-failures -->
|
|
1231
1240
|
|
|
1232
1241
|
- **A blob that never arrives.** A `data_want` unanswered for `data_wait_timeout` seconds on the account, 600 by default, fails that open with `RuntimeFailure` naming the relative path and the digest. The call is not killed, because the body may handle it; the failure is infrastructure, so a retry of the call is allowed.
|
|
1242
|
+
- **A sender that stops.** The background sender raising leaves every blob still queued unsendable. letify says so once, `letify: data sending stopped after <n> of <n> files, <n> not sent: <error>`, and tells the runtime, which wakes every reader waiting on one of those blobs with `RuntimeFailure` naming the file and that reason, and fails a reader that arrives later at once. Waiting it out instead costs `data_wait_timeout` once for every blob still queued, which is how a hundred queued shards become an hour of silence.
|
|
1233
1243
|
- **A client that disconnects mid stream.** The session ends as any lost channel ends it. Every blocked reader is woken with `RuntimeFailure`, the call ends with it, and no partial file is ever placed or read. A `.partial` file is removed when the worker next starts.
|
|
1234
1244
|
- **A call that finishes before the background transfer.** The transfer is cancelled, both the channel sender and any bucket upload not yet started. A blob already committed to the cache stays and counts as held for the next call, and a partial one is removed. The write-back and the `data_evict` request follow the cancellation, in that order.
|
|
1235
1245
|
- **A bucket-backed ephemeral provider.** A pull that fails is retried twice with the same downscoped headers, then a third time with headers the local process derives again, and a pull that still fails fails the open with `RuntimeFailure`. A `data_want` for a digest not yet uploaded moves that object to the front of the upload queue, and the worker pulls it when the local process sends its `data_pull`.
|
|
@@ -1290,6 +1300,10 @@ A call that detected data prints one line on standard error:
|
|
|
1290
1300
|
|
|
1291
1301
|
The line is printed when the call has at least one input file, after the call's outcome is known, because what was sent during the call is not known before then. Sizes are in MiB with one decimal. `<the bucket>` is named when the bytes come from the bucket. `before the call` counts the first wave of The send order and the first wave, `during it` counts what the background sender delivered while the call ran, and the rate covers both. `first access waited` is the wall clock time in which at least one reader was blocked on a blob that had not arrived, and it is `0.0` when the send order was right.
|
|
1292
1302
|
|
|
1303
|
+
`already on` counts the blobs the runtime answered `data_have` with, not what is left after subtracting what was sent. The two differ whenever a blob was neither already there nor fully sent, and subtracting reports such a blob as having been there all along, which is the opposite of what happened. A blob the background sender was still carrying when the call returned is one of those, so the line ends with `, <files> files <size> dropped when the call ended` whenever there is one, and omits that clause when there is none. A dropped blob is not a failure: the call is over and its bytes are no longer needed, which is why the sender is cancelled. It is reported because a line that accounts for every detected file except the dropped ones cannot be read.
|
|
1304
|
+
|
|
1305
|
+
Cancelling the sender removes the partial file of a blob it was writing, in the runtime's blob cache as well as a parallel put's staged ranges. A full sized partial left behind counts against the cache budget and is never completed, because the digest it would be sealed under is only written when every range has arrived.
|
|
1306
|
+
|
|
1293
1307
|
A call with an output location that returned prints a second line after the write-back:
|
|
1294
1308
|
|
|
1295
1309
|
`letify: data wrote back <files> files <size> in <seconds> s (<rate> MiB/s), <files> files <size> during the call, <files> files <size> already on the client`
|
|
@@ -1356,7 +1370,7 @@ Sizes are in MiB with one decimal and `<budget>` in GiB with one decimal.
|
|
|
1356
1370
|
|
|
1357
1371
|
`letify cache` shows the client digest cache, every local tree letify owns with its file count and size, and each provider's runtime file blob cache. `letify cache clear <target>` (or `letify cache clean <target>`) empties what is safe to clean. Credentials and configuration are never cleaned.
|
|
1358
1372
|
|
|
1359
|
-
`letify cache` first removes every digest cache entry whose file no longer exists, then prints the digest cache's entry count and how many were removed. It measures every local directory under `~/.letify`: `cache` (including digest cache, filesystem stores, and redirected CLI homes), `tools` (installed binaries), `tmp` (temporary session files), `ssh` (multiplexing control sockets), `runtime` (local provider workspace), and `accounts` (credentials, marked non-cleanable). For each provider in the configuration whose runtime disk persists between sessions
|
|
1373
|
+
`letify cache` first removes every digest cache entry whose file no longer exists, then prints the digest cache's entry count and how many were removed. It measures every local directory under `~/.letify`: `cache` (including digest cache, filesystem stores, and redirected CLI homes), `tools` (installed binaries), `tmp` (temporary session files), `ssh` (multiplexing control sockets), `runtime` (local provider workspace), and `accounts` (credentials, marked non-cleanable). For each provider in the configuration whose runtime disk persists between sessions and already has a session running, it asks the worker with one `data_cache` request and prints the alias, the blob count, the total size and the budget. It starts a session only where starting one bills nothing, which is a provider whose runtime is this machine: a command that shows what is there must not create what is billed, and a configuration naming several providers would bill for all of them at once to print a listing. A provider with no session running is listed with `no session running`, and a provider whose runtime disk does not persist with `not kept between sessions`. Clearing a named provider still starts a session, because there the user named the provider and asked for the work. `--json` prints the same records under `digests`, `local` and `providers`.
|
|
1360
1374
|
|
|
1361
1375
|
`letify cache clear <target>` (or `letify cache clean <target>`) clears caches:
|
|
1362
1376
|
1. When `<target>` is a provider alias, it sends `data_cache` with `clear` set to that runtime. The worker removes committed blobs whose link count is 1, and the command prints `<alias>: removed <files> files <size>`.
|
|
@@ -322,8 +322,41 @@ def _setup_tool(args: argparse.Namespace) -> int:
|
|
|
322
322
|
return 0
|
|
323
323
|
|
|
324
324
|
|
|
325
|
+
def _running_runtime(let: Launcher, alias: str):
|
|
326
|
+
"""A session this process already holds on that provider, or None.
|
|
327
|
+
|
|
328
|
+
Spec "The cache command": the listing asks only what is already running, because a
|
|
329
|
+
session it started would bill for the listing.
|
|
330
|
+
"""
|
|
331
|
+
for runtime in let.pool.live:
|
|
332
|
+
if getattr(runtime.provider, "alias", None) == alias:
|
|
333
|
+
return runtime
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _data_cache_reply(let: Launcher, runtime, clear: bool) -> dict:
|
|
338
|
+
"""One ``data_cache`` request to a session that is already up."""
|
|
339
|
+
from .store import pathdata
|
|
340
|
+
|
|
341
|
+
provider = runtime.provider
|
|
342
|
+
root = (runtime.workspace or provider.workspace_root).rstrip("/")
|
|
343
|
+
return runtime.request(
|
|
344
|
+
{
|
|
345
|
+
"op": "data_cache",
|
|
346
|
+
"dir": f"{root}/data/blobs",
|
|
347
|
+
"budget": pathdata.budget_bytes(provider),
|
|
348
|
+
"clear": clear,
|
|
349
|
+
},
|
|
350
|
+
timeout=600,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
325
354
|
def _data_cache_request(let: Launcher, alias: str, clear: bool) -> dict:
|
|
326
|
-
"""Ask a session on the provider's first instance about its file blob cache.
|
|
355
|
+
"""Ask a session on the provider's first instance about its file blob cache.
|
|
356
|
+
|
|
357
|
+
This starts one when none is running, so only a named provider reaches it. Spec "The
|
|
358
|
+
cache command".
|
|
359
|
+
"""
|
|
327
360
|
from .declare import Env
|
|
328
361
|
from .store import pathdata
|
|
329
362
|
|
|
@@ -462,7 +495,16 @@ def _cache(let: Launcher, args: argparse.Namespace) -> int:
|
|
|
462
495
|
if not provider.persistent:
|
|
463
496
|
records.append({"alias": alias, "kept": False})
|
|
464
497
|
continue
|
|
465
|
-
|
|
498
|
+
# Spec "The cache command": a listing bills nothing, so it starts a session
|
|
499
|
+
# only where starting one is free, and otherwise asks one that is already up.
|
|
500
|
+
if provider.kind == "local":
|
|
501
|
+
result = _data_cache_request(let, alias, False)
|
|
502
|
+
else:
|
|
503
|
+
runtime = _running_runtime(let, alias)
|
|
504
|
+
if runtime is None:
|
|
505
|
+
records.append({"alias": alias, "kept": True, "running": False})
|
|
506
|
+
continue
|
|
507
|
+
result = _data_cache_reply(let, runtime, False)
|
|
466
508
|
except LetifyError as exc:
|
|
467
509
|
records.append({"alias": alias, "unavailable": str(exc)})
|
|
468
510
|
continue
|
|
@@ -470,6 +512,7 @@ def _cache(let: Launcher, args: argparse.Namespace) -> int:
|
|
|
470
512
|
{
|
|
471
513
|
"alias": alias,
|
|
472
514
|
"kept": True,
|
|
515
|
+
"running": True,
|
|
473
516
|
"files": result["files"],
|
|
474
517
|
"bytes": result["bytes"],
|
|
475
518
|
"budget": result["budget"],
|
|
@@ -498,6 +541,8 @@ def _cache(let: Launcher, args: argparse.Namespace) -> int:
|
|
|
498
541
|
rows.append([row["alias"], f"unavailable: {row['unavailable']}", "", ""])
|
|
499
542
|
elif not row["kept"]:
|
|
500
543
|
rows.append([row["alias"], "not kept between sessions", "", ""])
|
|
544
|
+
elif not row.get("running"):
|
|
545
|
+
rows.append([row["alias"], "no session running", "", ""])
|
|
501
546
|
else:
|
|
502
547
|
rows.append(
|
|
503
548
|
[
|
|
@@ -364,6 +364,8 @@ def _op_call(request):
|
|
|
364
364
|
import shutil
|
|
365
365
|
_data_unregister(state)
|
|
366
366
|
_data_check_cache(data, state["placed"])
|
|
367
|
+
_DATA_FAILED.pop(data["dir"], None)
|
|
368
|
+
_DATA_FAILED.pop("", None)
|
|
367
369
|
_DATA_POLL_SEEN.pop(data["dir"], None)
|
|
368
370
|
_DATA_POLL_SENT.pop(data["dir"], None)
|
|
369
371
|
shutil.rmtree(data["dir"], ignore_errors=True)
|
|
@@ -855,6 +857,40 @@ def _parallel_partial(root, digest):
|
|
|
855
857
|
return "%s%s.parallel" % (_data_file(root, digest), _DATA_PARTIAL)
|
|
856
858
|
|
|
857
859
|
|
|
860
|
+
def _op_data_drop(request):
|
|
861
|
+
"""Remove the partials of a blob nobody is going to finish.
|
|
862
|
+
|
|
863
|
+
Spec "Data log line": a sender cancelled when the call ended leaves a partial that no
|
|
864
|
+
later request completes, because the digest it would be sealed under is written only
|
|
865
|
+
once every piece has arrived. A single put's partial is invisible to the budget scan,
|
|
866
|
+
so without this it stays for the life of the runtime.
|
|
867
|
+
"""
|
|
868
|
+
digest = request["digest"]
|
|
869
|
+
final = _data_file(request["dir"], digest)
|
|
870
|
+
open_handle = _DATA_OPEN.pop(digest, None)
|
|
871
|
+
if open_handle is not None:
|
|
872
|
+
try:
|
|
873
|
+
open_handle[0].close()
|
|
874
|
+
except OSError:
|
|
875
|
+
pass
|
|
876
|
+
removed = 0
|
|
877
|
+
directory = os.path.dirname(final)
|
|
878
|
+
name = os.path.basename(final)
|
|
879
|
+
try:
|
|
880
|
+
entries = os.listdir(directory)
|
|
881
|
+
except OSError:
|
|
882
|
+
entries = []
|
|
883
|
+
for entry in entries:
|
|
884
|
+
if entry == name or not entry.startswith(name + "."):
|
|
885
|
+
continue
|
|
886
|
+
try:
|
|
887
|
+
os.remove(os.path.join(directory, entry))
|
|
888
|
+
removed += 1
|
|
889
|
+
except OSError:
|
|
890
|
+
pass
|
|
891
|
+
return {"ok": True, "value": removed}
|
|
892
|
+
|
|
893
|
+
|
|
858
894
|
def _op_data_begin(request):
|
|
859
895
|
"""Make the file parallel senders will fill, of the size it will end up.
|
|
860
896
|
|
|
@@ -1013,6 +1049,9 @@ def _data_register(data):
|
|
|
1013
1049
|
"linked": 0,
|
|
1014
1050
|
"order": {},
|
|
1015
1051
|
"arrived": 0,
|
|
1052
|
+
# Spec "When a blob does not arrive": set when the local sender stops, so a
|
|
1053
|
+
# reader fails at once with its reason rather than waiting out the timeout.
|
|
1054
|
+
"failed": _DATA_FAILED.get(data["dir"]) or _DATA_FAILED.get(""),
|
|
1016
1055
|
"wait": float(data.get("wait") or 600.0),
|
|
1017
1056
|
"observe": bool(data.get("observe", True)),
|
|
1018
1057
|
"prefetch": int(data.get("prefetch") or 64),
|
|
@@ -1204,6 +1243,32 @@ def _data_arrived(blobs, digest):
|
|
|
1204
1243
|
_DATA_READY.notify_all()
|
|
1205
1244
|
|
|
1206
1245
|
|
|
1246
|
+
#: Reasons a sender stopped, by call directory, for a call that registers afterwards.
|
|
1247
|
+
_DATA_FAILED = {}
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def _op_data_failed(request):
|
|
1251
|
+
"""The local sender stopped, so the blobs it had left will never arrive.
|
|
1252
|
+
|
|
1253
|
+
Spec "When a blob does not arrive": every reader waiting on one of them is woken with
|
|
1254
|
+
the reason, and a reader that arrives later fails on it at once, because waiting out
|
|
1255
|
+
the per blob timeout costs that timeout once for every blob still queued.
|
|
1256
|
+
"""
|
|
1257
|
+
reason = request.get("reason") or "the local process stopped sending"
|
|
1258
|
+
where = request.get("dir") or ""
|
|
1259
|
+
with _DATA_READY:
|
|
1260
|
+
# Remembered by directory as well as set on the live call, because the sender can
|
|
1261
|
+
# stop before the call request has been read, and the call then registers after
|
|
1262
|
+
# this and would otherwise inherit nothing. Spec "When a blob does not arrive".
|
|
1263
|
+
_DATA_FAILED[where] = reason
|
|
1264
|
+
for state in _DATA_CALLS.values():
|
|
1265
|
+
if where and state["dir"] != where:
|
|
1266
|
+
continue
|
|
1267
|
+
state["failed"] = reason
|
|
1268
|
+
_DATA_READY.notify_all()
|
|
1269
|
+
return {"ok": True, "value": True}
|
|
1270
|
+
|
|
1271
|
+
|
|
1207
1272
|
def _data_want(digests, blocking=False):
|
|
1208
1273
|
"""Ask the local process for these blobs, in this order. Nothing replies to it."""
|
|
1209
1274
|
if not digests:
|
|
@@ -1270,6 +1335,10 @@ def _data_wait(path):
|
|
|
1270
1335
|
entry = state["pending"].get(path)
|
|
1271
1336
|
if entry is None:
|
|
1272
1337
|
return
|
|
1338
|
+
if state["failed"]:
|
|
1339
|
+
raise _DataFailure(
|
|
1340
|
+
"%s was never sent: %s" % (os.path.basename(path), state["failed"])
|
|
1341
|
+
)
|
|
1273
1342
|
digest, size = entry
|
|
1274
1343
|
index = state["order"].get(digest)
|
|
1275
1344
|
if index is not None and index > state["arrived"] + _DATA_MISS_AHEAD:
|
|
@@ -1287,6 +1356,11 @@ def _data_wait(path):
|
|
|
1287
1356
|
try:
|
|
1288
1357
|
with _DATA_READY:
|
|
1289
1358
|
while path in state["pending"] and state["dir"] in _DATA_CALLS:
|
|
1359
|
+
if state["failed"]:
|
|
1360
|
+
raise _DataFailure(
|
|
1361
|
+
"%s was never sent: %s"
|
|
1362
|
+
% (os.path.basename(path), state["failed"])
|
|
1363
|
+
)
|
|
1290
1364
|
left = deadline - time.monotonic()
|
|
1291
1365
|
if left <= 0:
|
|
1292
1366
|
raise _DataFailure(
|
|
@@ -2254,6 +2328,8 @@ _OPS = {
|
|
|
2254
2328
|
"data_have": _op_data_have,
|
|
2255
2329
|
"data_put": _op_data_put,
|
|
2256
2330
|
"data_begin": _op_data_begin,
|
|
2331
|
+
"data_drop": _op_data_drop,
|
|
2332
|
+
"data_failed": _op_data_failed,
|
|
2257
2333
|
"data_put_at": _op_data_put_at,
|
|
2258
2334
|
"data_seal": _op_data_seal,
|
|
2259
2335
|
"data_pull": _op_data_pull,
|
|
@@ -2279,7 +2355,9 @@ _OPS = {
|
|
|
2279
2355
|
}
|
|
2280
2356
|
|
|
2281
2357
|
#: Answered by the frame reader at once, so they work while a call runs.
|
|
2282
|
-
|
|
2358
|
+
# ``data_failed`` is light because it must not queue behind the call it is about: the call
|
|
2359
|
+
# is blocked on a blob the sender will never send, so a queued answer would deadlock.
|
|
2360
|
+
_LIGHT = ("stat", "lease", "reduce_peers", "data_failed")
|
|
2283
2361
|
|
|
2284
2362
|
#: Answered by several replies, the last one marked. These send their own replies.
|
|
2285
2363
|
_STREAMING = ("data_stream",)
|
|
@@ -419,7 +419,7 @@ class Runtime:
|
|
|
419
419
|
needed = [d for d in plan.missing if d in ship_digests and d not in wave]
|
|
420
420
|
if needed:
|
|
421
421
|
wave = needed + wave
|
|
422
|
-
stream = pathdata.Stream(self, blobs, plan)
|
|
422
|
+
stream = pathdata.Stream(self, blobs, plan, collector.call_dir)
|
|
423
423
|
if not plan.missing:
|
|
424
424
|
# Nothing to stream: the runtime holds every file, so the call places them all
|
|
425
425
|
# before the body starts. It needs no manifest, no patch and no observation.
|
|
@@ -23,7 +23,7 @@ from importlib import import_module
|
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
from typing import TYPE_CHECKING, Any
|
|
25
25
|
|
|
26
|
-
from ..errors import ConfigError, RemoteError, RuntimeFailure
|
|
26
|
+
from ..errors import ConfigError, ProtocolError, RemoteError, RuntimeFailure
|
|
27
27
|
|
|
28
28
|
if TYPE_CHECKING:
|
|
29
29
|
from ..providers.base import Provider
|
|
@@ -370,6 +370,17 @@ class Collector:
|
|
|
370
370
|
if resolved == project or resolved in project.parents:
|
|
371
371
|
return None
|
|
372
372
|
if not any(_inside(resolved, root) for root in roots):
|
|
373
|
+
if info is not None:
|
|
374
|
+
# Spec "Where the bytes come from": a file that exists here and cannot be
|
|
375
|
+
# sent would travel as a path the body cannot read, which fails on the
|
|
376
|
+
# runtime naming neither the cause nor this rule, or reads the wrong bytes
|
|
377
|
+
# where something does sit at that path.
|
|
378
|
+
raise ValueError(
|
|
379
|
+
f"{resolved} is outside the roots letify may send data from. The "
|
|
380
|
+
f"project root is {project}. Add its directory to [tool.letify] "
|
|
381
|
+
f"data_roots in {project}/pyproject.toml, or move the file inside "
|
|
382
|
+
f"the root."
|
|
383
|
+
)
|
|
373
384
|
return None
|
|
374
385
|
index = len(self.placed)
|
|
375
386
|
placed = Placed(resolved, f"{self.call_dir}/{index}/{resolved.name}", is_dir)
|
|
@@ -597,10 +608,15 @@ class Stream:
|
|
|
597
608
|
order after the call starts comes from the runtime rather than from the prediction.
|
|
598
609
|
"""
|
|
599
610
|
|
|
600
|
-
def __init__(self, runtime: Runtime, blobs: str, plan: Plan) -> None:
|
|
611
|
+
def __init__(self, runtime: Runtime, blobs: str, plan: Plan, call_dir: str = "") -> None:
|
|
601
612
|
self.runtime = runtime
|
|
602
613
|
self.blobs = blobs
|
|
603
614
|
self.plan = plan
|
|
615
|
+
#: The call this stream feeds, so a stopped sender fails that call's readers only.
|
|
616
|
+
self.call_dir = call_dir
|
|
617
|
+
#: Digests whose send finished. ``_sent`` holds the ones it began, so a blob the
|
|
618
|
+
#: sender was carrying when the call ended is in one and not the other.
|
|
619
|
+
self.done: set[str] = set()
|
|
604
620
|
self.before_files = self.before_bytes = 0
|
|
605
621
|
self.during_files = self.during_bytes = 0
|
|
606
622
|
self.before_seconds = self.during_seconds = 0.0
|
|
@@ -648,13 +664,35 @@ class Stream:
|
|
|
648
664
|
break
|
|
649
665
|
# The pieces still in flight are answered before the stream is released.
|
|
650
666
|
self._flush()
|
|
651
|
-
except Exception:
|
|
652
|
-
#
|
|
653
|
-
#
|
|
654
|
-
|
|
667
|
+
except Exception as exc:
|
|
668
|
+
# Spec "When a blob does not arrive": the sender stopping leaves every blob
|
|
669
|
+
# still queued unsendable, so it is said once and the readers waiting on one
|
|
670
|
+
# are failed with the reason. Waiting it out costs the per blob timeout once
|
|
671
|
+
# for each, which is how 131 queued shards became a silent hour.
|
|
672
|
+
if not self._cancelled.is_set():
|
|
673
|
+
self._stopped(exc)
|
|
655
674
|
finally:
|
|
656
675
|
self.during_seconds = time.monotonic() - started
|
|
657
676
|
|
|
677
|
+
def _stopped(self, exc: BaseException) -> None:
|
|
678
|
+
"""Say the sender stopped, and tell the runtime so its readers stop waiting."""
|
|
679
|
+
from .. import install
|
|
680
|
+
|
|
681
|
+
left = self.dropped
|
|
682
|
+
reason = f"{type(exc).__name__}: {exc}"
|
|
683
|
+
install.log(
|
|
684
|
+
f"data sending stopped after {len(self.done)} of "
|
|
685
|
+
f"{len(self.plan.missing)} files, {len(left)} not sent: {reason}"
|
|
686
|
+
)
|
|
687
|
+
try:
|
|
688
|
+
_worker(
|
|
689
|
+
self.runtime,
|
|
690
|
+
{"op": "data_failed", "dir": self.call_dir, "reason": reason},
|
|
691
|
+
)
|
|
692
|
+
except (RuntimeFailure, RemoteError, ProtocolError):
|
|
693
|
+
# The runtime is gone, which fails every reader on its own.
|
|
694
|
+
pass
|
|
695
|
+
|
|
658
696
|
def _pump(self) -> bool:
|
|
659
697
|
"""Send the next blob of the queue. False once nothing is left to send."""
|
|
660
698
|
self.reorder()
|
|
@@ -703,6 +741,22 @@ class Stream:
|
|
|
703
741
|
if (thread is None or not thread.is_alive()) and self._pipeline is not None:
|
|
704
742
|
self._pipeline.close()
|
|
705
743
|
self._pipeline = None
|
|
744
|
+
self._drop_partials()
|
|
745
|
+
|
|
746
|
+
def _drop_partials(self) -> None:
|
|
747
|
+
"""Remove the partial of every blob the sender was carrying. Spec "Data log line"."""
|
|
748
|
+
for digest in self.dropped:
|
|
749
|
+
try:
|
|
750
|
+
_worker(self.runtime, {"op": "data_drop", "dir": self.blobs, "digest": digest})
|
|
751
|
+
except (RuntimeFailure, RemoteError):
|
|
752
|
+
# The runtime is going away, which removes the partial with it.
|
|
753
|
+
return
|
|
754
|
+
|
|
755
|
+
@property
|
|
756
|
+
def dropped(self) -> list[str]:
|
|
757
|
+
"""Blobs that were neither already on the runtime nor fully sent."""
|
|
758
|
+
with self._lock:
|
|
759
|
+
return [digest for digest in self.plan.missing if digest not in self.done]
|
|
706
760
|
|
|
707
761
|
# -- the stream ---------------------------------------------------------------
|
|
708
762
|
|
|
@@ -760,6 +814,8 @@ class Stream:
|
|
|
760
814
|
if not uploaded:
|
|
761
815
|
return
|
|
762
816
|
files, total = self.plan.weight(digest)
|
|
817
|
+
with self._lock:
|
|
818
|
+
self.done.add(digest)
|
|
763
819
|
if self._thread is None:
|
|
764
820
|
self.before_files += files
|
|
765
821
|
self.before_bytes += total
|
|
@@ -780,19 +836,31 @@ class Stream:
|
|
|
780
836
|
|
|
781
837
|
stats = stats or {}
|
|
782
838
|
plan = self.plan
|
|
783
|
-
sent_files = self.before_files + self.during_files
|
|
784
839
|
sent_bytes = self.before_bytes + self.during_bytes
|
|
785
840
|
seconds = self.before_seconds + self.during_seconds
|
|
786
841
|
rate = sent_bytes / (1 << 20) / seconds if seconds > 0 else 0.0
|
|
787
|
-
|
|
842
|
+
# Spec "Data log line": counted from what the runtime answered data_have with,
|
|
843
|
+
# because subtracting what was sent reports a blob that was neither as having
|
|
844
|
+
# been there all along.
|
|
845
|
+
missing_weight = [plan.weight(digest) for digest in plan.missing]
|
|
846
|
+
already_files = plan.detected_files - sum(files for files, _b in missing_weight)
|
|
847
|
+
already_bytes = plan.detected_bytes - sum(total for _f, total in missing_weight)
|
|
848
|
+
dropped_weight = [plan.weight(digest) for digest in self.dropped]
|
|
849
|
+
dropped_files = sum(files for files, _b in dropped_weight)
|
|
850
|
+
dropped_bytes = sum(total for _f, total in dropped_weight)
|
|
851
|
+
line = (
|
|
788
852
|
f"data {plan.detected_files} files {_mib(plan.detected_bytes)} detected, "
|
|
789
|
-
f"{
|
|
790
|
-
f"{_mib(plan.detected_bytes - sent_bytes)} already on {plan.where}, "
|
|
853
|
+
f"{already_files} files {_mib(already_bytes)} already on {plan.where}, "
|
|
791
854
|
f"sent {self.before_files} files {_mib(self.before_bytes)} before the call in "
|
|
792
855
|
f"{self.before_seconds:.1f} s, {self.during_files} files "
|
|
793
856
|
f"{_mib(self.during_bytes)} during it in {self.during_seconds:.1f} s "
|
|
794
857
|
f"({rate:.1f} MiB/s), first access waited {float(stats.get('seconds', 0.0)):.1f} s"
|
|
795
858
|
)
|
|
859
|
+
if dropped_files:
|
|
860
|
+
line += (
|
|
861
|
+
f", {dropped_files} files {_mib(dropped_bytes)} dropped when the call ended"
|
|
862
|
+
)
|
|
863
|
+
install.log(line)
|
|
796
864
|
if stats.get("files"):
|
|
797
865
|
install.log(
|
|
798
866
|
f"data waited for {stats['files']} files {_mib(int(stats.get('bytes', 0)))} "
|
|
@@ -138,7 +138,7 @@ dependencies = [
|
|
|
138
138
|
|
|
139
139
|
[[package]]
|
|
140
140
|
name = "letify-agent"
|
|
141
|
-
version = "2.5.
|
|
141
|
+
version = "2.5.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.5.
|
|
149
|
+
version = "2.5.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.5.
|
|
167
|
+
version = "2.5.4"
|
|
168
168
|
|
|
169
169
|
[[package]]
|
|
170
170
|
name = "libc"
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letify-status",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.4",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "letify-status",
|
|
9
|
-
"version": "2.5.
|
|
9
|
+
"version": "2.5.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.5.
|
|
5
|
+
"version": "2.5.4",
|
|
6
6
|
"publisher": "darkpyonix",
|
|
7
7
|
"license": "SEE LICENSE IN LICENSE",
|
|
8
8
|
"repository": {
|
|
@@ -1606,3 +1606,61 @@ def test_stop_orphans_exits_one_when_a_delete_failed(isolated_home, capsys, monk
|
|
|
1606
1606
|
)
|
|
1607
1607
|
assert main(["stop", "--orphans", "-y"]) == 1
|
|
1608
1608
|
assert "refused" in capsys.readouterr().out
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
def test_the_cache_listing_starts_no_session(isolated_home, monkeypatch, capsys) -> None:
|
|
1612
|
+
"""Spec "The cache command": a command that shows what is there starts nothing.
|
|
1613
|
+
|
|
1614
|
+
KICT ran `letify cache` and it booted a Modal CPU session, which then failed its own
|
|
1615
|
+
uv sync. A configuration naming several persistent providers would bill for all of
|
|
1616
|
+
them to print a listing.
|
|
1617
|
+
"""
|
|
1618
|
+
import letify
|
|
1619
|
+
from letify.cli import main
|
|
1620
|
+
from letify.runtime import pool
|
|
1621
|
+
|
|
1622
|
+
(isolated_home / ".letify" / "config.toml").write_text(
|
|
1623
|
+
'[lab]\nkind = "shell"\naddress = "gpu.example.edu"\npersistent = true\n',
|
|
1624
|
+
encoding="utf-8",
|
|
1625
|
+
)
|
|
1626
|
+
started: list[str] = []
|
|
1627
|
+
|
|
1628
|
+
real = pool.RuntimePool.acquire
|
|
1629
|
+
|
|
1630
|
+
def watch(self, instance, env, volumes=(), **kwargs):
|
|
1631
|
+
provider = instance.provider
|
|
1632
|
+
started.append(provider.alias)
|
|
1633
|
+
assert provider.kind == "local", f"the listing started a billed session on {provider.alias}"
|
|
1634
|
+
return real(self, instance, env, volumes, **kwargs)
|
|
1635
|
+
|
|
1636
|
+
monkeypatch.setattr(pool.RuntimePool, "acquire", watch)
|
|
1637
|
+
assert main(["cache", "--json"]) == 0
|
|
1638
|
+
assert all(alias == "local" for alias in started), started
|
|
1639
|
+
out = capsys.readouterr().out
|
|
1640
|
+
assert "providers" in out
|
|
1641
|
+
assert letify # the import is what makes the launcher reachable in this process
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def test_the_cache_listing_says_a_provider_has_no_session_running(
|
|
1645
|
+
isolated_home, capsys
|
|
1646
|
+
) -> None:
|
|
1647
|
+
"""Spec "The cache command": a provider with no session is named, not skipped."""
|
|
1648
|
+
import json
|
|
1649
|
+
|
|
1650
|
+
from letify.cli import main
|
|
1651
|
+
|
|
1652
|
+
(isolated_home / ".letify" / "config.toml").write_text(
|
|
1653
|
+
'[lab]\nkind = "shell"\naddress = "gpu.example.edu"\npersistent = true\n',
|
|
1654
|
+
encoding="utf-8",
|
|
1655
|
+
)
|
|
1656
|
+
assert main(["cache", "--json"]) == 0
|
|
1657
|
+
record = json.loads(capsys.readouterr().out)
|
|
1658
|
+
remote = [
|
|
1659
|
+
row
|
|
1660
|
+
for row in record["providers"]
|
|
1661
|
+
if row.get("kept") and row["alias"] != "local"
|
|
1662
|
+
]
|
|
1663
|
+
assert remote, "no billed persistent provider in the fixture, so this pins nothing"
|
|
1664
|
+
for row in remote:
|
|
1665
|
+
assert row.get("running") is False, row
|
|
1666
|
+
assert "files" not in row, row
|
|
@@ -106,9 +106,12 @@ def test_a_path_that_does_not_exist_outside_the_roots_is_left_as_a_plain_path(
|
|
|
106
106
|
assert not absent.exists()
|
|
107
107
|
|
|
108
108
|
|
|
109
|
-
def
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
def test_a_file_outside_the_allowed_roots_is_refused(let, cpu, project, tmp_path) -> None:
|
|
110
|
+
"""Spec "Where the bytes come from": it used to travel as a plain path.
|
|
111
|
+
|
|
112
|
+
The body then read a path that is not on the runtime, which failed there as a
|
|
113
|
+
FileNotFoundError naming neither the cause nor the rule. KICT hit that twice.
|
|
114
|
+
"""
|
|
112
115
|
outside = tmp_path / "elsewhere.txt"
|
|
113
116
|
outside.write_text("not project data", encoding="utf-8")
|
|
114
117
|
|
|
@@ -116,8 +119,9 @@ def test_a_path_outside_the_allowed_roots_is_left_as_a_plain_path(
|
|
|
116
119
|
def where(path: Path) -> str:
|
|
117
120
|
return str(path)
|
|
118
121
|
|
|
119
|
-
|
|
120
|
-
|
|
122
|
+
with pytest.raises(ValueError) as failure:
|
|
123
|
+
where(outside)
|
|
124
|
+
assert "outside the roots letify may send data from" in str(failure.value)
|
|
121
125
|
|
|
122
126
|
|
|
123
127
|
def test_a_data_root_named_in_pyproject_is_allowed(let, cpu, project, tmp_path) -> None:
|
|
@@ -780,3 +784,40 @@ def test_blobs_a_write_back_adds_keep_the_cache_within_its_budget(
|
|
|
780
784
|
root = _blobs_root()
|
|
781
785
|
assert sum(p.stat().st_size for p in root.glob("*/*")) <= 20 << 10
|
|
782
786
|
assert len(evictions(capsys.readouterr().err)) == 1
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def test_a_file_outside_the_allowed_roots_is_refused_rather_than_sent_as_a_path(
|
|
790
|
+
let, cpu, project, tmp_path
|
|
791
|
+
) -> None:
|
|
792
|
+
"""Spec "Where the bytes come from": passing it on leaves the body reading nothing.
|
|
793
|
+
|
|
794
|
+
KICT hit this twice on Kaggle: a shard outside the project root travelled as a plain
|
|
795
|
+
path and the call died on the runtime with FileNotFoundError naming only the file.
|
|
796
|
+
"""
|
|
797
|
+
outside = tmp_path / "outside" / "shard.pt"
|
|
798
|
+
outside.parent.mkdir(parents=True, exist_ok=True)
|
|
799
|
+
outside.write_bytes(b"x" * 4096)
|
|
800
|
+
|
|
801
|
+
@let.function(device=cpu, host="remote")
|
|
802
|
+
def read(path) -> int:
|
|
803
|
+
import os
|
|
804
|
+
|
|
805
|
+
return os.path.getsize(path)
|
|
806
|
+
|
|
807
|
+
with pytest.raises(ValueError) as failure:
|
|
808
|
+
read(path=outside)
|
|
809
|
+
message = str(failure.value)
|
|
810
|
+
assert str(outside) in message
|
|
811
|
+
assert "data_roots" in message
|
|
812
|
+
assert "outside the roots letify may send data from" in message
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def test_a_path_that_does_not_exist_here_still_travels_unchanged(let, cpu, project) -> None:
|
|
816
|
+
"""Spec "Where the bytes come from": that is how a body is told where to write."""
|
|
817
|
+
|
|
818
|
+
@let.function(device=cpu, host="remote")
|
|
819
|
+
def name(path) -> str:
|
|
820
|
+
return str(path)
|
|
821
|
+
|
|
822
|
+
target = Path("/not/on/this/machine/out.pt")
|
|
823
|
+
assert name(path=target) == str(target)
|