letify 2.6.7__tar.gz → 2.7.0__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.6.7 → letify-2.7.0}/PKG-INFO +2 -2
- {letify-2.6.7 → letify-2.7.0}/README.md +1 -1
- {letify-2.6.7 → letify-2.7.0}/docs/SPEC.md +58 -22
- {letify-2.6.7 → letify-2.7.0}/docs/locales/README_ko.md +1 -1
- {letify-2.6.7 → letify-2.7.0}/letify/__init__.py +1 -1
- {letify-2.6.7 → letify-2.7.0}/letify/config/__init__.py +4 -3
- {letify-2.6.7 → letify-2.7.0}/letify/config/login.py +3 -1
- {letify-2.6.7 → letify-2.7.0}/letify/config/secrets.py +3 -1
- {letify-2.6.7 → letify-2.7.0}/letify/install.py +6 -2
- {letify-2.6.7 → letify-2.7.0}/letify/launcher.py +2 -1
- {letify-2.6.7 → letify-2.7.0}/letify/paths.py +65 -11
- {letify-2.6.7 → letify-2.7.0}/letify/protocol/worker.py +77 -0
- {letify-2.6.7 → letify-2.7.0}/letify/providers/elice.py +3 -2
- {letify-2.6.7 → letify-2.7.0}/letify/remoting/device/value.py +3 -2
- {letify-2.6.7 → letify-2.7.0}/letify/runtime/bootstrap.py +65 -0
- {letify-2.6.7 → letify-2.7.0}/letify/runtime/session.py +140 -9
- {letify-2.6.7 → letify-2.7.0}/letify/store/backends/filesystem.py +21 -0
- {letify-2.6.7 → letify-2.7.0}/letify/store/cas.py +50 -0
- {letify-2.6.7 → letify-2.7.0}/letify/store/volume.py +26 -2
- {letify-2.6.7 → letify-2.7.0}/letify-core/Cargo.lock +3 -3
- {letify-2.6.7 → letify-2.7.0}/letify-core/Cargo.toml +1 -1
- {letify-2.6.7 → letify-2.7.0}/letify-ext/package-lock.json +2 -2
- {letify-2.6.7 → letify-2.7.0}/letify-ext/package.json +1 -1
- {letify-2.6.7 → letify-2.7.0}/pyproject.toml +1 -1
- {letify-2.6.7 → letify-2.7.0}/tests/test_paths.py +179 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_runtime.py +196 -22
- {letify-2.6.7 → letify-2.7.0}/.github/workflows/ci.yml +0 -0
- {letify-2.6.7 → letify-2.7.0}/.github/workflows/docs.yml +0 -0
- {letify-2.6.7 → letify-2.7.0}/.github/workflows/publish.yml +0 -0
- {letify-2.6.7 → letify-2.7.0}/.gitignore +0 -0
- {letify-2.6.7 → letify-2.7.0}/.researchtree +0 -0
- {letify-2.6.7 → letify-2.7.0}/CLAUDE.md +0 -0
- {letify-2.6.7 → letify-2.7.0}/LICENSE +0 -0
- {letify-2.6.7 → letify-2.7.0}/PROJECT.md +0 -0
- {letify-2.6.7 → letify-2.7.0}/docs/COMPONENT.md +0 -0
- {letify-2.6.7 → letify-2.7.0}/docs/INTENT.md +0 -0
- {letify-2.6.7 → letify-2.7.0}/docs/NETWORK.md +0 -0
- {letify-2.6.7 → letify-2.7.0}/docs/guide/01-getting-started.md +0 -0
- {letify-2.6.7 → letify-2.7.0}/docs/guide/02-providers.md +0 -0
- {letify-2.6.7 → letify-2.7.0}/docs/guide/03-execution-modes.md +0 -0
- {letify-2.6.7 → letify-2.7.0}/docs/guide/04-environments-and-data.md +0 -0
- {letify-2.6.7 → letify-2.7.0}/docs/guide/05-concurrency.md +0 -0
- {letify-2.6.7 → letify-2.7.0}/docs/guide/06-cost.md +0 -0
- {letify-2.6.7 → letify-2.7.0}/docs/guide/07-troubleshooting.md +0 -0
- {letify-2.6.7 → letify-2.7.0}/docs/guide/08-releasing.md +0 -0
- {letify-2.6.7 → letify-2.7.0}/docs/guide/README.md +0 -0
- {letify-2.6.7 → letify-2.7.0}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
- {letify-2.6.7 → letify-2.7.0}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
- {letify-2.6.7 → letify-2.7.0}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
- {letify-2.6.7 → letify-2.7.0}/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.6.7 → letify-2.7.0}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
- {letify-2.6.7 → letify-2.7.0}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-2.6.7 → letify-2.7.0}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
- {letify-2.6.7 → letify-2.7.0}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-2.6.7 → letify-2.7.0}/docs/guide/ko/README.md +0 -0
- {letify-2.6.7 → letify-2.7.0}/docs/mkdocs.yml +0 -0
- {letify-2.6.7 → letify-2.7.0}/examples/README.md +0 -0
- {letify-2.6.7 → letify-2.7.0}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-2.6.7 → letify-2.7.0}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/examples/nvfp4-lora/README.md +0 -0
- {letify-2.6.7 → letify-2.7.0}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-2.6.7 → letify-2.7.0}/examples/nvfp4-lora/common.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/cli.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/config/inventory.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/config/schema.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/config/writer.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/declare/__init__.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/declare/cache.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/declare/env.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/declare/function.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/declare/instance.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/declare/reduce.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/declare/replicas.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/errors.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/protocol/__init__.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/protocol/codec.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/protocol/driver.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/protocol/handle.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/protocol/wire.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/providers/__init__.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/providers/base.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/providers/colab.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/providers/colab_files.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/providers/colab_keepalive.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/providers/kaggle.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/providers/kaggle_adapter.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/providers/local.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/providers/modal.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/providers/modal_adapter.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/providers/naming.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/providers/shell.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/providers/tunnel.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/providers/usage.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/remoting/__init__.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/remoting/capability.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/remoting/device/__init__.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/remoting/device/client.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/remoting/device/cuda.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/remoting/device/executor.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/remoting/device/frames.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/remoting/device/guard.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/remoting/device/tensor.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/remoting/device/trace.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/remoting/loader.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/remoting/probe.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/render.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/runtime/__init__.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/runtime/broker.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/runtime/channel.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/runtime/lease.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/runtime/pool.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/runtime/telemetry.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/store/__init__.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/store/backends/__init__.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/store/backends/google_auth.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/store/backends/layout.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/store/backends/objects.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/store/pathdata.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/store/sendorder.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/stubs.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/tools.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/transport/__init__.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/transport/agent.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/transport/announce.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/transport/link.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/transport/nat.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/transport/pipeline.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/transport/portmap.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/transport/probe.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/transport/quic.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/transport/rendezvous.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/transport/setup.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/transport/sshopts.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify/transport/strategies.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-core/.gitignore +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-core/build.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-core/letify-quic/Cargo.toml +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-core/letify-quic/src/main.rs +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-core/rust-toolchain.toml +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-ext/.vscodeignore +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-ext/LICENSE +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-ext/README.md +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-ext/media/icon.svg +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-ext/src/cli.ts +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-ext/src/extension.ts +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-ext/src/history.ts +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-ext/src/model.ts +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-ext/src/poll.ts +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-ext/src/render.ts +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-ext/test/cli.test.ts +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-ext/test/manifest.test.ts +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-ext/test/model.test.ts +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-ext/test/regressions.test.ts +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify-ext/tsconfig.json +0 -0
- {letify-2.6.7 → letify-2.7.0}/letify_providers.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/scripts/sync-main.sh +0 -0
- {letify-2.6.7 → letify-2.7.0}/scripts/version.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/conftest.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/fake_kaggle_adapter.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/fake_modal_adapter.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_cli.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_cli_output.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_colab_keepalive.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_config.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_core.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_data.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_data_streaming.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_declare.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_device.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_distributed.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_editor_sessions.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_elice.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_frames.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_install.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_kaggle.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_kaggle_cookie.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_kaggle_link.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_link_log.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_notebook.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_parallel_placement.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_portmap.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_protocol.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_providers.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_quic.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_remoting.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_render.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_ship.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_store.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_strategies.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_stubs.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_transport.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_tunnel_setup.py +0 -0
- {letify-2.6.7 → letify-2.7.0}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: letify
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.0
|
|
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
|
|
@@ -186,7 +186,7 @@ letify login colab colab_pro_plus
|
|
|
186
186
|
letify login shell lab_a100
|
|
187
187
|
```
|
|
188
188
|
|
|
189
|
-
Each command writes two files. The account goes to `~/.letify/config.toml`, which belongs to your machine and never to the repository
|
|
189
|
+
Each command writes two files. The account goes to `~/.letify/config.toml`, which belongs to your machine and never to the repository. Set `LETIFY_HOME` to an absolute path to keep all of letify's local state in another directory instead of `~/.letify`:
|
|
190
190
|
|
|
191
191
|
```toml
|
|
192
192
|
[colab_pro_plus]
|
|
@@ -161,7 +161,7 @@ letify login colab colab_pro_plus
|
|
|
161
161
|
letify login shell lab_a100
|
|
162
162
|
```
|
|
163
163
|
|
|
164
|
-
Each command writes two files. The account goes to `~/.letify/config.toml`, which belongs to your machine and never to the repository
|
|
164
|
+
Each command writes two files. The account goes to `~/.letify/config.toml`, which belongs to your machine and never to the repository. Set `LETIFY_HOME` to an absolute path to keep all of letify's local state in another directory instead of `~/.letify`:
|
|
165
165
|
|
|
166
166
|
```toml
|
|
167
167
|
[colab_pro_plus]
|
|
@@ -1076,15 +1076,29 @@ less time than the build did. The archive is smaller than the directory, so the
|
|
|
1076
1076
|
overstates the transfer and errs toward building, which is the cheaper of the two to get wrong. The
|
|
1077
1077
|
ref records the build seconds beside the archive.
|
|
1078
1078
|
|
|
1079
|
-
With no measured rate for the link, letify packs
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1079
|
+
With no measured rate for the link, letify packs a directory of any size when the archive streams,
|
|
1080
|
+
and says `letify: <runtime>: caching the environment, <size> GB` first. The archive streams when the
|
|
1081
|
+
first volume's store keeps blobs as files (the filesystem backend, which the automatic environment
|
|
1082
|
+
store uses) and the channel is persistent. Both directions then move the archive in pieces and
|
|
1083
|
+
neither side holds it whole:
|
|
1084
|
+
|
|
1085
|
+
- Pack: the worker op `pack_blob` writes the same tar.gz as `pack_dir`, with the same member rules,
|
|
1086
|
+
into a staging directory beside the project directory while hashing the bytes it writes. It
|
|
1087
|
+
returns `{"digest", "size", "dir"}`. The client receives the file with the stream a write-back
|
|
1088
|
+
uses, checks the digest as the pieces arrive into a scratch file beside the store, and renames it
|
|
1089
|
+
into the store with `Store.adopt_file`. The staging directory is removed afterwards. The digest
|
|
1090
|
+
is the store's content digest (blake3 truncated to 16 bytes, blake2b without blake3).
|
|
1091
|
+
- Restore: a blob of more than one piece (8 MiB) that the store holds as a file is sent with
|
|
1092
|
+
`data_put` pieces into a staging directory beside the destination, then moved into place and
|
|
1093
|
+
unpacked by the worker op `place_blob`. Smaller blobs, and any blob from a store that does not
|
|
1094
|
+
keep files, go as one `put_file` payload.
|
|
1095
|
+
|
|
1096
|
+
With no measured rate and a store that does not stream (the GCS backend) or a channel that is not
|
|
1097
|
+
persistent, letify packs only when the directory is at most 1 GiB, and says why when it declines,
|
|
1098
|
+
because that pack moves the archive as one payload that the worker and the client each hold whole.
|
|
1099
|
+
Measured: a 7.2 GB Modal environment sat in that whole-payload pack for over 30 minutes while the
|
|
1100
|
+
session billed, and a Kaggle session restoring a 3.0 GB archive held whole took 540 s. A streamed
|
|
1101
|
+
transfer ran at 85 to 88 MiB/s. A bucket would need a resumable upload to stream.
|
|
1088
1102
|
|
|
1089
1103
|
A later session restores only when the archive's size over the link measured for that session would
|
|
1090
1104
|
take less than the recorded build. Otherwise it syncs, and says why:
|
|
@@ -1123,7 +1137,7 @@ The follow-up design stores each model shard as one content addressed blob and p
|
|
|
1123
1137
|
|
|
1124
1138
|
### Volumes on a persistent runtime <!-- id: persistent-volumes -->
|
|
1125
1139
|
|
|
1126
|
-
> On a persistent provider the volume directory under the workspace root is the runtime's copy of the volume. A file is sent only when that copy does not already hold its digest, and no environment archive is packed or restored.
|
|
1140
|
+
> On a persistent provider the volume directory under the workspace root is the runtime's copy of the volume. A file is sent only when that copy does not already hold its digest, and no environment archive is packed or restored, except on the workspace root as below.
|
|
1127
1141
|
|
|
1128
1142
|
A persistent runtime keeps `<workspace root>/volumes/<volume name>` between sessions, so a later session already holds what an earlier one received. The volume directory holds a manifest, `.letify-manifest.json`, mapping each materialized destination path to the digest, size in bytes and modification time in nanoseconds it had when it was written.
|
|
1129
1143
|
|
|
@@ -1131,7 +1145,27 @@ Before a blob is written to a destination that is not unpacked, the local proces
|
|
|
1131
1145
|
|
|
1132
1146
|
A persistent provider builds its environment with `uv sync` in `<workspace root>/project/<env key>` every session, and never packs the project directory into a volume or unpacks an archive from one. A sync over an existing `.venv` checks it and installs nothing, so it is faster than any archive transfer, and the `.venv` is already on the disk the archive would be unpacked to.
|
|
1133
1147
|
|
|
1134
|
-
That holds only while the project directory is under the workspace root. A provider that keeps its workspace root and
|
|
1148
|
+
That holds only while the project directory is under the workspace root. A provider that keeps its workspace root and builds the `.venv` elsewhere keeps the environment archive on that workspace root, not in a store and never through the client. Modal is that provider: its workspace root is a Modal volume that outlives every sandbox, and its `env_root` is the sandbox's own disk, chosen because a cold import from the volume reads thousands of small files over the network.
|
|
1149
|
+
|
|
1150
|
+
The archive is `<workspace root>/env-archives/<env key>-<platform>.tar`, uncompressed. Measured on a Modal T4 with a 5.5 GB project directory of 33 packages including torch:
|
|
1151
|
+
|
|
1152
|
+
| Step | Time |
|
|
1153
|
+
|---|---|
|
|
1154
|
+
| `uv sync` build | 216 s |
|
|
1155
|
+
| pack through the client's filesystem store | about 700 s |
|
|
1156
|
+
| restore through the client's filesystem store | 727 s |
|
|
1157
|
+
| `tar -cf` to the volume, uncompressed | 70.8 s |
|
|
1158
|
+
| `tar -xf` from the volume, uncompressed | 31.9 s |
|
|
1159
|
+
| `tar.gz` to the volume | 240.1 s, gzip is CPU bound |
|
|
1160
|
+
| `tar.gz` from the volume | 39.8 s, 3.0 GB |
|
|
1161
|
+
|
|
1162
|
+
The client's inbound link is the weak one, so the store path is slower than the build in both directions. Compression costs more than it saves on the volume, so the archive is not compressed.
|
|
1163
|
+
|
|
1164
|
+
After a successful sync and interpreter check, the runtime says `caching the environment on the workspace root, <size>`, runs tar from the project directory's parent into a temporary name beside the destination, and renames it into place, so a concurrent session never reads a half written archive. Nothing is sent to the client, and no measured rate gates the pack, because the transfer stays inside the provider.
|
|
1165
|
+
|
|
1166
|
+
At the start of `install_env`, when that archive exists on the runtime, the runtime says `restoring the environment from the workspace root`, extracts it into the project directory's parent, and validates the interpreter with the same check an archive restore uses. The restore refuses a member whose path escapes the destination and allows the absolute symlinks a `.venv` needs. A directory an earlier session left under the same name is removed first. When validation fails, the runtime removes the extracted tree, syncs, and replaces the archive. `Runtime.env_source` is `"workspace"` after this restore. This path is taken before and instead of the store path, and such a provider never uses the client store or the bucket store for its environment.
|
|
1167
|
+
|
|
1168
|
+
A session sees the archive only once the volume shows it, which is not at once on Modal: a session started right after the one that packed rebuilt and packed again, and a session started a few minutes later restored and its whole call took 44.2 s, against 106.6 s and 183.8 s for the two that built. The rebuild that follows such a miss is not wasted work so much as repeated work, and it leaves the same archive behind.
|
|
1135
1169
|
|
|
1136
1170
|
An ephemeral provider keeps the behaviour of Materializing into a runtime: every file is sent each session, and the environment archive is packed and restored.
|
|
1137
1171
|
|
|
@@ -1975,25 +2009,27 @@ The worker's working directory is the root, so a relative path in user code reso
|
|
|
1975
2009
|
|
|
1976
2010
|
### Declared filesystem paths <!-- id: declared-paths -->
|
|
1977
2011
|
|
|
1978
|
-
> letify writes exclusively to declared directories: locally under
|
|
2012
|
+
> letify writes exclusively to declared directories: locally under `~/.letify` or the directory `LETIFY_HOME` names, and on runtimes within the designated workspace root, with explicit documented exceptions for system SSH and daemon runtime directories.
|
|
1979
2013
|
|
|
1980
2014
|
Every path letify creates or writes is declared here. The system temporary directory (/tmp, /var/tmp, or tempfile.gettempdir()) is never used, locally or on a runtime.
|
|
1981
2015
|
|
|
1982
2016
|
#### Local machine
|
|
1983
2017
|
|
|
1984
|
-
Local filesystem modifications are consolidated under the single root `~/.letify`. The declared local paths are
|
|
2018
|
+
Local filesystem modifications are consolidated under the single root `~/.letify`. The declared local paths are listed below, written relative to `~/.letify`:
|
|
2019
|
+
|
|
2020
|
+
`LETIFY_HOME`, when set and non-empty, replaces `~/.letify` as the root of every local path in the table below, and `~` in it is expanded. A relative value raises `ConfigError` naming the value and saying it must be absolute, because a root that moves with the working directory would split one machine's state across directories. Unset or empty means `~/.letify`. `LETIFY_CONFIG` moves only the config file and wins over `LETIFY_HOME` for that file. The fallbacks that read `~/.cache/letify/...` apply only when `LETIFY_HOME` is unset, because a moved root is a separate letify state and does not read the default one's caches. With both `LETIFY_HOME` and `XDG_RUNTIME_DIR` set, the SSH control directory is `$XDG_RUNTIME_DIR/letify-<h>`, where `<h>` is the first 8 hex characters of the SHA-256 of the resolved root, so two roots on one machine never share multiplexed connections and the socket path stays short. The Local provider workspace follows the root. Messages that tell a person which file to edit name the path in use.
|
|
1985
2021
|
|
|
1986
2022
|
| Path | Purpose | Movable by |
|
|
1987
2023
|
|---|---|---|
|
|
1988
|
-
| `~/.letify/config.toml` | Machine configuration declaring accounts and global providers | `LETIFY_CONFIG` environment variable or project `.letify/config.toml` |
|
|
1989
|
-
| `~/.letify/accounts/<alias>/` | Account credentials and secrets (mode 0700 directory, mode 0600 files). Holds secret files only: `token.json`, `modal.toml`, `eci.yaml`, `cookie`, `access_token`, `username`, `notebook_id`, `known_hosts`, `link.json`, `password`. Never contains CLI cache directories or python packages |
|
|
1990
|
-
| `~/.letify/tools/<tool>/<version>/` | Installed standalone binaries (`tailcat`, `eci`). Older versions under `~/.letify/tools/<tool>/` are automatically pruned on install |
|
|
1991
|
-
| `~/.letify/cache/digests.json` | Local project file digest cache. Falls back to reading `~/.cache/letify/digests.json` if it exists from an earlier install |
|
|
1992
|
-
| `~/.letify/cache/storage/<name>` | Filesystem storage backend root for store `<name>`. Falls back to reading `~/.cache/letify/<name>` if it exists |
|
|
1993
|
-
| `~/.letify/cache/tools/<alias>/` | Redirected `HOME` and `USERPROFILE` for provider CLIs (`colab`, `kaggle`). Mode 0700. Keeps CLI temporary files, packages, and `.config/` out of the credential directory. The CLI finds its token via a symbolic link pointing to `~/.letify/accounts/<alias>/token.json` (or copy fallback on systems without symlink support) |
|
|
1994
|
-
| `~/.letify/tmp/` | Temporary directory for ephemeral keys during reverse SSH negotiation and adapter stderr files. Mode 0700 |
|
|
1995
|
-
| `~/.letify/ssh/` | OpenSSH multiplexing control sockets (`ControlPath`). Mode 0700 | `XDG_RUNTIME_DIR` (moved to `$XDG_RUNTIME_DIR/letify`) |
|
|
1996
|
-
| `~/.letify/runtime/` | Local provider workspace root for calls and local volumes. Holds `data/blobs`, `data/calls`, `tmp`. Falls back to reading `~/.letify-runtime` if it exists |
|
|
2024
|
+
| `~/.letify/config.toml` | Machine configuration declaring accounts and global providers | `LETIFY_CONFIG` environment variable (the file only), `LETIFY_HOME`, or project `.letify/config.toml` |
|
|
2025
|
+
| `~/.letify/accounts/<alias>/` | Account credentials and secrets (mode 0700 directory, mode 0600 files). Holds secret files only: `token.json`, `modal.toml`, `eci.yaml`, `cookie`, `access_token`, `username`, `notebook_id`, `known_hosts`, `link.json`, `password`. Never contains CLI cache directories or python packages | `LETIFY_HOME` |
|
|
2026
|
+
| `~/.letify/tools/<tool>/<version>/` | Installed standalone binaries (`tailcat`, `eci`). Older versions under `~/.letify/tools/<tool>/` are automatically pruned on install | `LETIFY_HOME` |
|
|
2027
|
+
| `~/.letify/cache/digests.json` | Local project file digest cache. Falls back to reading `~/.cache/letify/digests.json` if it exists from an earlier install | `LETIFY_HOME` |
|
|
2028
|
+
| `~/.letify/cache/storage/<name>` | Filesystem storage backend root for store `<name>`. Falls back to reading `~/.cache/letify/<name>` if it exists | `LETIFY_HOME` |
|
|
2029
|
+
| `~/.letify/cache/tools/<alias>/` | Redirected `HOME` and `USERPROFILE` for provider CLIs (`colab`, `kaggle`). Mode 0700. Keeps CLI temporary files, packages, and `.config/` out of the credential directory. The CLI finds its token via a symbolic link pointing to `~/.letify/accounts/<alias>/token.json` (or copy fallback on systems without symlink support) | `LETIFY_HOME` |
|
|
2030
|
+
| `~/.letify/tmp/` | Temporary directory for ephemeral keys during reverse SSH negotiation and adapter stderr files. Mode 0700 | `LETIFY_HOME` |
|
|
2031
|
+
| `~/.letify/ssh/` | OpenSSH multiplexing control sockets (`ControlPath`). Mode 0700 | `XDG_RUNTIME_DIR` (moved to `$XDG_RUNTIME_DIR/letify`), `LETIFY_HOME` (with `XDG_RUNTIME_DIR`: `$XDG_RUNTIME_DIR/letify-<h>`; without it: `$LETIFY_HOME/ssh`) |
|
|
2032
|
+
| `~/.letify/runtime/` | Local provider workspace root for calls and local volumes. Holds `data/blobs`, `data/calls`, `tmp`. Falls back to reading `~/.letify-runtime` if it exists | `LETIFY_HOME` |
|
|
1997
2033
|
|
|
1998
2034
|
**Documented local exceptions:**
|
|
1999
2035
|
|
|
@@ -155,7 +155,7 @@ letify login colab colab_pro_plus
|
|
|
155
155
|
letify login shell lab_a100
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
-
명령 하나가 파일 두 개를 씁니다. 계정은 `~/.letify/config.toml`에 들어갑니다. 머신에 속하는 정보이고, 저장소에는 들어가지 않습니다.
|
|
158
|
+
명령 하나가 파일 두 개를 씁니다. 계정은 `~/.letify/config.toml`에 들어갑니다. 머신에 속하는 정보이고, 저장소에는 들어가지 않습니다. letify의 로컬 상태 전체를 `~/.letify` 대신 다른 디렉터리에 두려면 `LETIFY_HOME`을 절대 경로로 설정하세요.
|
|
159
159
|
|
|
160
160
|
```toml
|
|
161
161
|
[colab_pro_plus]
|
|
@@ -16,6 +16,7 @@ from pathlib import Path
|
|
|
16
16
|
from typing import Any
|
|
17
17
|
|
|
18
18
|
from ..errors import ConfigError
|
|
19
|
+
from ..paths import config_label, config_path
|
|
19
20
|
from . import inventory, login, writer
|
|
20
21
|
from .schema import RESERVED_ALIASES, Config, ProviderConfig
|
|
21
22
|
from .secrets import CONFIG_DIRECTORY, account_directory, resolve_secret
|
|
@@ -49,7 +50,7 @@ def load(path: str | Path | None = None, *, home: bool = True) -> Config:
|
|
|
49
50
|
which is what tests do to stay isolated from the developer's own accounts.
|
|
50
51
|
"""
|
|
51
52
|
config = Config()
|
|
52
|
-
home_path =
|
|
53
|
+
home_path = config_path()
|
|
53
54
|
project_path = _project_file(path)
|
|
54
55
|
|
|
55
56
|
home_entries = _read_entries(home_path, config, require_kind=True) if home else {}
|
|
@@ -66,14 +67,14 @@ def load(path: str | Path | None = None, *, home: bool = True) -> Config:
|
|
|
66
67
|
if WORKSPACE_FIELD in body:
|
|
67
68
|
raise ConfigError(
|
|
68
69
|
f"{project_path}: {alias!r} sets 'workspace', which belongs in the home file "
|
|
69
|
-
f"
|
|
70
|
+
f"{config_label()}, because where letify may write is a rule of one "
|
|
70
71
|
f"machine rather than of the repository"
|
|
71
72
|
)
|
|
72
73
|
base = home_entries.get(alias, {})
|
|
73
74
|
kind = body.get("kind", base.get("kind"))
|
|
74
75
|
if not isinstance(kind, str):
|
|
75
76
|
raise ConfigError(
|
|
76
|
-
f"{project_path}: {alias!r} names an account that
|
|
77
|
+
f"{project_path}: {alias!r} names an account that {config_label()} does not "
|
|
77
78
|
f"have. "
|
|
78
79
|
f"Run 'letify login <kind> {alias}' to declare it on this machine, or give "
|
|
79
80
|
f"the table a 'kind' to declare it here."
|
|
@@ -995,7 +995,9 @@ def forget_secret(alias: str) -> bool:
|
|
|
995
995
|
|
|
996
996
|
|
|
997
997
|
def home_path() -> Path:
|
|
998
|
-
|
|
998
|
+
from ..paths import config_path
|
|
999
|
+
|
|
1000
|
+
return config_path()
|
|
999
1001
|
|
|
1000
1002
|
|
|
1001
1003
|
def project_path(path: str | Path | None = None) -> Path:
|
|
@@ -28,7 +28,9 @@ ACCOUNTS_DIRECTORY = "accounts"
|
|
|
28
28
|
|
|
29
29
|
def account_directory(alias: str) -> Path:
|
|
30
30
|
"""``~/.letify/accounts/<alias>/``, whether or not it exists yet."""
|
|
31
|
-
|
|
31
|
+
from ..paths import account_directory as declared
|
|
32
|
+
|
|
33
|
+
return declared(alias)
|
|
32
34
|
|
|
33
35
|
|
|
34
36
|
def resolve_secret(
|
|
@@ -140,7 +140,9 @@ def _executable(tool: str) -> str:
|
|
|
140
140
|
|
|
141
141
|
|
|
142
142
|
def tools_home() -> Path:
|
|
143
|
-
|
|
143
|
+
from .paths import tools_directory
|
|
144
|
+
|
|
145
|
+
return tools_directory()
|
|
144
146
|
|
|
145
147
|
|
|
146
148
|
def cache_path(tool: str) -> Path:
|
|
@@ -165,7 +167,9 @@ def auto_install_enabled() -> bool:
|
|
|
165
167
|
value = os.environ.get("LETIFY_AUTO_INSTALL")
|
|
166
168
|
if value is not None:
|
|
167
169
|
return value.strip().lower() not in ("0", "false", "no")
|
|
168
|
-
|
|
170
|
+
from .paths import config_path
|
|
171
|
+
|
|
172
|
+
home_file = config_path()
|
|
169
173
|
try:
|
|
170
174
|
settings = tomllib.loads(home_file.read_text(encoding="utf-8"))
|
|
171
175
|
except (OSError, tomllib.TOMLDecodeError):
|
|
@@ -42,6 +42,7 @@ from .declare.env import Env
|
|
|
42
42
|
from .declare.function import Function
|
|
43
43
|
from .declare.instance import AnyInstance, Host, Instance, RemoteOnlyInstance
|
|
44
44
|
from .errors import LetifyError, UnknownInstance, UnknownProvider
|
|
45
|
+
from .paths import config_label
|
|
45
46
|
from .runtime.pool import RuntimePool
|
|
46
47
|
|
|
47
48
|
if TYPE_CHECKING:
|
|
@@ -168,7 +169,7 @@ class Launcher:
|
|
|
168
169
|
known = ", ".join(self.config.order) or "none"
|
|
169
170
|
raise UnknownProvider(
|
|
170
171
|
f"no provider is declared under {alias!r}. Declared: {known}. "
|
|
171
|
-
f"Name it in .letify/config.toml, and declare the account in
|
|
172
|
+
f"Name it in .letify/config.toml, and declare the account in {config_label()}."
|
|
172
173
|
)
|
|
173
174
|
provider = provider_registry.build(entry)
|
|
174
175
|
# Connection decisions are printed only when the session start line is.
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
"""Declared filesystem paths for letify.
|
|
2
2
|
|
|
3
3
|
Spec section "Declared filesystem paths":
|
|
4
|
-
letify writes exclusively to declared directories: locally under ~/.letify
|
|
5
|
-
runtimes within the designated workspace root, with
|
|
6
|
-
for system SSH and daemon runtime directories. The system
|
|
7
|
-
is never used.
|
|
4
|
+
letify writes exclusively to declared directories: locally under ~/.letify (or the
|
|
5
|
+
directory LETIFY_HOME names), and on runtimes within the designated workspace root, with
|
|
6
|
+
explicit documented exceptions for system SSH and daemon runtime directories. The system
|
|
7
|
+
temporary directory (/tmp) is never used.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import hashlib
|
|
12
13
|
import os
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
|
|
16
|
+
from .errors import ConfigError
|
|
17
|
+
|
|
15
18
|
#: Root directory for letify state, tools and caches on the local machine.
|
|
16
19
|
CONFIG_DIRECTORY = ".letify"
|
|
17
20
|
|
|
@@ -80,16 +83,58 @@ DECLARED_REMOTE_EXCEPTIONS = frozenset(
|
|
|
80
83
|
)
|
|
81
84
|
|
|
82
85
|
|
|
86
|
+
def home_is_moved() -> bool:
|
|
87
|
+
"""Whether ``LETIFY_HOME`` is set and non-empty."""
|
|
88
|
+
return bool(os.environ.get("LETIFY_HOME"))
|
|
89
|
+
|
|
90
|
+
|
|
83
91
|
def letify_root() -> Path:
|
|
84
|
-
"""The local state root directory: ~/.letify.
|
|
85
|
-
|
|
92
|
+
"""The local state root directory: ``LETIFY_HOME`` when set, else ~/.letify.
|
|
93
|
+
|
|
94
|
+
``~`` in ``LETIFY_HOME`` is expanded. A relative value raises ``ConfigError``, because a
|
|
95
|
+
root that moves with the working directory would split one machine's state.
|
|
96
|
+
"""
|
|
97
|
+
value = os.environ.get("LETIFY_HOME")
|
|
98
|
+
if not value:
|
|
99
|
+
return Path.home() / CONFIG_DIRECTORY
|
|
100
|
+
root = Path(value).expanduser()
|
|
101
|
+
if not root.is_absolute():
|
|
102
|
+
raise ConfigError(f"LETIFY_HOME={value!r} must be an absolute path")
|
|
103
|
+
return root
|
|
86
104
|
|
|
87
105
|
|
|
88
106
|
def config_path() -> Path:
|
|
89
|
-
"""The local machine configuration file: ~/.letify/config.toml.
|
|
107
|
+
"""The local machine configuration file: ~/.letify/config.toml.
|
|
108
|
+
|
|
109
|
+
``LETIFY_CONFIG`` names another file and wins over ``LETIFY_HOME`` for this file only.
|
|
110
|
+
"""
|
|
111
|
+
named = os.environ.get("LETIFY_CONFIG")
|
|
112
|
+
if named:
|
|
113
|
+
return Path(named).expanduser()
|
|
90
114
|
return letify_root() / "config.toml"
|
|
91
115
|
|
|
92
116
|
|
|
117
|
+
def config_label() -> str:
|
|
118
|
+
"""The machine configuration file as a message names it: the path in use."""
|
|
119
|
+
if os.environ.get("LETIFY_CONFIG") or home_is_moved():
|
|
120
|
+
return str(config_path())
|
|
121
|
+
return "~/.letify/config.toml"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def tools_label() -> str:
|
|
125
|
+
"""The installed tools directory as a message names it: the path in use."""
|
|
126
|
+
if home_is_moved():
|
|
127
|
+
return str(tools_directory())
|
|
128
|
+
return "~/.letify/tools"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def accounts_label() -> str:
|
|
132
|
+
"""The account credentials directory as a message names it: the path in use."""
|
|
133
|
+
if home_is_moved():
|
|
134
|
+
return str(accounts_directory())
|
|
135
|
+
return "~/.letify/accounts"
|
|
136
|
+
|
|
137
|
+
|
|
93
138
|
def accounts_directory() -> Path:
|
|
94
139
|
"""The account credentials directory: ~/.letify/accounts."""
|
|
95
140
|
return letify_root() / "accounts"
|
|
@@ -114,9 +159,11 @@ def digest_cache_path() -> Path:
|
|
|
114
159
|
"""The path to the client digest cache file.
|
|
115
160
|
|
|
116
161
|
Returns ~/.letify/cache/digests.json, falling back to reading
|
|
117
|
-
~/.cache/letify/digests.json if it exists from an earlier install.
|
|
162
|
+
~/.cache/letify/digests.json if it exists from an earlier install, unless LETIFY_HOME is set.
|
|
118
163
|
"""
|
|
119
164
|
new_path = cache_directory() / "digests.json"
|
|
165
|
+
if home_is_moved():
|
|
166
|
+
return new_path
|
|
120
167
|
old_path = Path.home() / ".cache" / "letify" / "digests.json"
|
|
121
168
|
if not new_path.exists() and old_path.exists():
|
|
122
169
|
return old_path
|
|
@@ -130,7 +177,7 @@ def storage_cache_directory(name: str) -> Path:
|
|
|
130
177
|
if it pre-exists from an earlier install.
|
|
131
178
|
"""
|
|
132
179
|
old_path = Path.home() / ".cache" / "letify" / name
|
|
133
|
-
if old_path.exists():
|
|
180
|
+
if not home_is_moved() and old_path.exists():
|
|
134
181
|
return old_path
|
|
135
182
|
return cache_directory() / "storage" / name
|
|
136
183
|
|
|
@@ -148,17 +195,24 @@ def local_tmp_directory() -> Path:
|
|
|
148
195
|
def ssh_control_directory() -> Path:
|
|
149
196
|
"""The directory for OpenSSH multiplexing control sockets.
|
|
150
197
|
|
|
151
|
-
Returns $XDG_RUNTIME_DIR/letify if set, otherwise ~/.letify/ssh.
|
|
198
|
+
Returns $XDG_RUNTIME_DIR/letify if set, otherwise ~/.letify/ssh. With LETIFY_HOME set
|
|
199
|
+
the directory is $XDG_RUNTIME_DIR/letify-<8 hex of the root's SHA-256>, so two roots
|
|
200
|
+
never share connections and the socket path stays short.
|
|
152
201
|
The system temporary directory (/tmp) is never used.
|
|
153
202
|
"""
|
|
154
203
|
runtime = os.environ.get("XDG_RUNTIME_DIR")
|
|
155
204
|
if runtime:
|
|
205
|
+
if home_is_moved():
|
|
206
|
+
digest = hashlib.sha256(str(letify_root().resolve()).encode("utf-8")).hexdigest()
|
|
207
|
+
return Path(runtime) / f"letify-{digest[:8]}"
|
|
156
208
|
return Path(runtime) / "letify"
|
|
157
209
|
return letify_root() / "ssh"
|
|
158
210
|
|
|
159
211
|
|
|
160
212
|
def local_runtime_root() -> str:
|
|
161
|
-
"""The local provider workspace root string: ~/.letify/runtime."""
|
|
213
|
+
"""The local provider workspace root string: ~/.letify/runtime, or under LETIFY_HOME."""
|
|
214
|
+
if home_is_moved():
|
|
215
|
+
return str(local_runtime_directory())
|
|
162
216
|
return "~/.letify/runtime"
|
|
163
217
|
|
|
164
218
|
|
|
@@ -2263,6 +2263,81 @@ def _op_pack_dir(request):
|
|
|
2263
2263
|
}
|
|
2264
2264
|
|
|
2265
2265
|
|
|
2266
|
+
class _HashedFile:
|
|
2267
|
+
"""A write-only file that hashes every byte it passes on, so no second read is needed."""
|
|
2268
|
+
|
|
2269
|
+
def __init__(self, handle, hasher):
|
|
2270
|
+
self._handle = handle
|
|
2271
|
+
self._hasher = hasher
|
|
2272
|
+
self.size = 0
|
|
2273
|
+
|
|
2274
|
+
def write(self, data):
|
|
2275
|
+
self._handle.write(data)
|
|
2276
|
+
self._hasher.update(data)
|
|
2277
|
+
self.size += len(data)
|
|
2278
|
+
return len(data)
|
|
2279
|
+
|
|
2280
|
+
def flush(self):
|
|
2281
|
+
self._handle.flush()
|
|
2282
|
+
|
|
2283
|
+
def tell(self):
|
|
2284
|
+
return self.size
|
|
2285
|
+
|
|
2286
|
+
|
|
2287
|
+
def _op_pack_blob(request):
|
|
2288
|
+
"""Pack a directory into the blob cache as one committed file, hashing as it writes.
|
|
2289
|
+
|
|
2290
|
+
Same archive and member rules as ``pack_dir``, but the archive never exists as one
|
|
2291
|
+
bytes object: it is written to a partial file in pieces, and the caller reads it back
|
|
2292
|
+
with ``data_stream``. The reply names the directory it used, with ``~`` expanded.
|
|
2293
|
+
"""
|
|
2294
|
+
root = os.path.expanduser(request["path"])
|
|
2295
|
+
directory = os.path.expanduser(request["dir"])
|
|
2296
|
+
os.makedirs(directory, exist_ok=True)
|
|
2297
|
+
partial = os.path.join(directory, ".pack.%d.partial" % os.getpid())
|
|
2298
|
+
hasher = _data_hasher()
|
|
2299
|
+
if hasher is None:
|
|
2300
|
+
hasher = hashlib.blake2b(digest_size=16)
|
|
2301
|
+
try:
|
|
2302
|
+
with open(partial, "wb") as handle:
|
|
2303
|
+
hashed = _HashedFile(handle, hasher)
|
|
2304
|
+
with tarfile.open(fileobj=hashed, mode="w:gz") as archive:
|
|
2305
|
+
archive.add(root, arcname=os.path.basename(root.rstrip("/")), recursive=True)
|
|
2306
|
+
digest = hasher.hexdigest() if hasher.name == "blake2b" else hasher.hexdigest(length=16)
|
|
2307
|
+
final = _data_file(directory, digest)
|
|
2308
|
+
os.makedirs(os.path.dirname(final), exist_ok=True)
|
|
2309
|
+
os.replace(partial, final)
|
|
2310
|
+
finally:
|
|
2311
|
+
if os.path.exists(partial):
|
|
2312
|
+
os.remove(partial)
|
|
2313
|
+
return {"ok": True, "value": {"digest": digest, "size": hashed.size, "dir": directory}}
|
|
2314
|
+
|
|
2315
|
+
|
|
2316
|
+
def _op_place_blob(request):
|
|
2317
|
+
"""Move a received file blob to its destination path, and unpack it when asked.
|
|
2318
|
+
|
|
2319
|
+
The sender wrote the blob into a staging directory with ``data_put``. The staging
|
|
2320
|
+
directory is removed once the file has moved.
|
|
2321
|
+
"""
|
|
2322
|
+
import shutil
|
|
2323
|
+
|
|
2324
|
+
staging = request["dir"]
|
|
2325
|
+
source = _data_file(staging, request["digest"])
|
|
2326
|
+
path = request["path"]
|
|
2327
|
+
directory = os.path.dirname(path)
|
|
2328
|
+
if directory:
|
|
2329
|
+
os.makedirs(directory, exist_ok=True)
|
|
2330
|
+
os.replace(source, path)
|
|
2331
|
+
try:
|
|
2332
|
+
os.chmod(path, 0o644)
|
|
2333
|
+
except OSError:
|
|
2334
|
+
pass
|
|
2335
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
2336
|
+
if request.get("unpack"):
|
|
2337
|
+
_unpack(path, request.get("target") or os.path.dirname(path), request.get("links"))
|
|
2338
|
+
return {"ok": True, "value": {"path": path, "size": os.path.getsize(path)}}
|
|
2339
|
+
|
|
2340
|
+
|
|
2266
2341
|
def _op_exec(request):
|
|
2267
2342
|
"""Run plain source inside the worker, sharing its globals."""
|
|
2268
2343
|
exec(compile(request["source"], "<letify>", "exec"), globals())
|
|
@@ -2380,6 +2455,8 @@ _OPS = {
|
|
|
2380
2455
|
"pull": _op_pull,
|
|
2381
2456
|
"get_file": _op_get_file,
|
|
2382
2457
|
"pack_dir": _op_pack_dir,
|
|
2458
|
+
"pack_blob": _op_pack_blob,
|
|
2459
|
+
"place_blob": _op_place_blob,
|
|
2383
2460
|
"exec": _op_exec,
|
|
2384
2461
|
"eval": _op_eval,
|
|
2385
2462
|
"release": _op_release,
|
|
@@ -43,6 +43,7 @@ from ..errors import (
|
|
|
43
43
|
SpotPreempted,
|
|
44
44
|
UnsupportedMode,
|
|
45
45
|
)
|
|
46
|
+
from ..paths import accounts_label, tools_label
|
|
46
47
|
from .naming import normalize_gpu
|
|
47
48
|
from .shell import Shell
|
|
48
49
|
from .usage import Usage
|
|
@@ -205,7 +206,7 @@ def install_question() -> str:
|
|
|
205
206
|
|
|
206
207
|
return (
|
|
207
208
|
"eci, Elice's command line, is not installed. letify can download eci "
|
|
208
|
-
f"{install.ECI_VERSION} from Elice's GitHub release into
|
|
209
|
+
f"{install.ECI_VERSION} from Elice's GitHub release into {tools_label()}; it is "
|
|
209
210
|
"Elice's software, not part of letify. Install it now? [y/N]: "
|
|
210
211
|
)
|
|
211
212
|
|
|
@@ -426,7 +427,7 @@ class Elice(Shell):
|
|
|
426
427
|
raise ProviderUnavailable(
|
|
427
428
|
self.kind,
|
|
428
429
|
f"{self.alias} needs an access token. Run 'letify login elice {self.alias}', "
|
|
429
|
-
f"which keeps it in
|
|
430
|
+
f"which keeps it in {accounts_label()}/{self.alias}/, or set access_token_env",
|
|
430
431
|
)
|
|
431
432
|
return token
|
|
432
433
|
|
|
@@ -14,7 +14,6 @@ import math
|
|
|
14
14
|
import os
|
|
15
15
|
import tomllib
|
|
16
16
|
from collections.abc import Callable
|
|
17
|
-
from pathlib import Path
|
|
18
17
|
from typing import Any
|
|
19
18
|
|
|
20
19
|
#: Attributes answered without resolving. Everything else waits for the value first.
|
|
@@ -33,7 +32,9 @@ def auto_fetch_enabled(configured: bool | None) -> bool:
|
|
|
33
32
|
|
|
34
33
|
def _home_setting() -> bool:
|
|
35
34
|
"""``auto_fetch`` at the top level of ``~/.letify/config.toml``, on when absent."""
|
|
36
|
-
|
|
35
|
+
from ...paths import config_path
|
|
36
|
+
|
|
37
|
+
home_file = config_path()
|
|
37
38
|
try:
|
|
38
39
|
settings = tomllib.loads(home_file.read_text(encoding="utf-8"))
|
|
39
40
|
except (OSError, tomllib.TOMLDecodeError):
|
|
@@ -275,6 +275,69 @@ def venv_check_source(python: str, archive: str | None = None) -> str:
|
|
|
275
275
|
return "\n".join(lines) + "\n"
|
|
276
276
|
|
|
277
277
|
|
|
278
|
+
def workspace_pack_source(root: str, archive: str) -> str:
|
|
279
|
+
"""Source that packs a project directory into an uncompressed tar beside its destination.
|
|
280
|
+
|
|
281
|
+
Spec "Volumes on a persistent runtime": runs ``tar`` from the directory's parent into a
|
|
282
|
+
temporary name and renames it into place, so a concurrent session never reads a half
|
|
283
|
+
written archive. Uncompressed, because gzip costs more than it saves on a local volume.
|
|
284
|
+
"""
|
|
285
|
+
return (
|
|
286
|
+
"import os, subprocess\n"
|
|
287
|
+
f"_root = os.path.expanduser({root!r}).rstrip('/')\n"
|
|
288
|
+
f"_archive = os.path.expanduser({archive!r})\n"
|
|
289
|
+
"os.makedirs(os.path.dirname(_archive), exist_ok=True)\n"
|
|
290
|
+
"_partial = _archive + '.' + str(os.getpid()) + '.partial'\n"
|
|
291
|
+
"try:\n"
|
|
292
|
+
" subprocess.run(['tar', '-cf', _partial, '-C', os.path.dirname(_root),\n"
|
|
293
|
+
" os.path.basename(_root)], check=True, capture_output=True)\n"
|
|
294
|
+
" os.replace(_partial, _archive)\n"
|
|
295
|
+
"finally:\n"
|
|
296
|
+
" if os.path.exists(_partial):\n"
|
|
297
|
+
" os.remove(_partial)\n"
|
|
298
|
+
"__letify_value__ = os.path.getsize(_archive)\n"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def workspace_restore_source(archive: str, target: str) -> str:
|
|
303
|
+
"""Source that extracts a workspace archive into ``target``, or answers False without one.
|
|
304
|
+
|
|
305
|
+
Spec "Volumes on a persistent runtime": a member whose path escapes ``target`` is
|
|
306
|
+
refused, and the absolute symlinks a ``.venv`` needs are allowed, as the worker's
|
|
307
|
+
environment unpacking does with ``links=True``.
|
|
308
|
+
"""
|
|
309
|
+
return (
|
|
310
|
+
"import os, shutil, tarfile\n"
|
|
311
|
+
f"_archive = os.path.expanduser({archive!r})\n"
|
|
312
|
+
f"_target = os.path.expanduser({target!r})\n"
|
|
313
|
+
"if not os.path.isfile(_archive):\n"
|
|
314
|
+
" __letify_value__ = False\n"
|
|
315
|
+
"else:\n"
|
|
316
|
+
" os.makedirs(_target, exist_ok=True)\n"
|
|
317
|
+
" _real = os.path.abspath(_target)\n"
|
|
318
|
+
" with tarfile.open(_archive, 'r:') as _packed:\n"
|
|
319
|
+
" for _member in _packed.getmembers():\n"
|
|
320
|
+
" # Lexical, because a tree from an earlier session already holds symlinks\n"
|
|
321
|
+
" # that realpath would follow out of the destination.\n"
|
|
322
|
+
" _where = os.path.normpath(os.path.join(_real, _member.name))\n"
|
|
323
|
+
" if os.path.commonpath([_real, _where]) != _real:\n"
|
|
324
|
+
" raise ValueError('archive member would escape the destination')\n"
|
|
325
|
+
" # A tree left by an earlier session holds symlinks that extraction would\n"
|
|
326
|
+
" # follow, so the top level names the archive holds are replaced whole.\n"
|
|
327
|
+
" for _top in {_m.name.split('/')[0] for _m in _packed.getmembers()}:\n"
|
|
328
|
+
" _old = os.path.join(_real, _top)\n"
|
|
329
|
+
" if os.path.islink(_old) or os.path.isfile(_old):\n"
|
|
330
|
+
" os.remove(_old)\n"
|
|
331
|
+
" elif os.path.isdir(_old):\n"
|
|
332
|
+
" shutil.rmtree(_old)\n"
|
|
333
|
+
" try:\n"
|
|
334
|
+
" _packed.extractall(_target, filter='tar')\n"
|
|
335
|
+
" except TypeError:\n"
|
|
336
|
+
" _packed.extractall(_target)\n"
|
|
337
|
+
" __letify_value__ = True\n"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
278
341
|
def tree_size_source(root: str) -> str:
|
|
279
342
|
"""Source that sums the byte size of every regular file under a directory.
|
|
280
343
|
|
|
@@ -455,4 +518,6 @@ __all__ = [
|
|
|
455
518
|
"uv_bin_dir",
|
|
456
519
|
"uv_env",
|
|
457
520
|
"venv_check_source",
|
|
521
|
+
"workspace_pack_source",
|
|
522
|
+
"workspace_restore_source",
|
|
458
523
|
]
|