letify 2.4.7__tar.gz → 2.5.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.5.0/CLAUDE.md +157 -0
- {letify-2.4.7 → letify-2.5.0}/PKG-INFO +1 -1
- letify-2.5.0/PROJECT.md +301 -0
- letify-2.5.0/docs/COMPONENT.md +200 -0
- letify-2.5.0/docs/INTENT.md +116 -0
- letify-2.5.0/docs/NETWORK.md +327 -0
- letify-2.5.0/docs/SPEC.md +2579 -0
- {letify-2.4.7 → letify-2.5.0}/letify/__init__.py +7 -1
- {letify-2.4.7 → letify-2.5.0}/letify/declare/function.py +21 -2
- letify-2.5.0/letify/declare/reduce.py +305 -0
- {letify-2.4.7 → letify-2.5.0}/letify/errors.py +9 -0
- {letify-2.4.7 → letify-2.5.0}/letify/protocol/wire.py +6 -0
- {letify-2.4.7 → letify-2.5.0}/letify/protocol/worker.py +36 -1
- {letify-2.4.7 → letify-2.5.0}/letify/providers/kaggle.py +24 -8
- {letify-2.4.7 → letify-2.5.0}/letify/runtime/bootstrap.py +23 -0
- letify-2.5.0/letify/runtime/broker.py +166 -0
- {letify-2.4.7 → letify-2.5.0}/letify/runtime/channel.py +20 -0
- {letify-2.4.7 → letify-2.5.0}/letify/runtime/session.py +95 -4
- {letify-2.4.7 → letify-2.5.0}/letify/store/volume.py +29 -1
- {letify-2.4.7 → letify-2.5.0}/letify-core/Cargo.lock +3 -3
- {letify-2.4.7 → letify-2.5.0}/letify-core/Cargo.toml +1 -1
- {letify-2.4.7 → letify-2.5.0}/letify-ext/package-lock.json +2 -2
- {letify-2.4.7 → letify-2.5.0}/letify-ext/package.json +1 -1
- {letify-2.4.7 → letify-2.5.0}/pyproject.toml +1 -1
- {letify-2.4.7 → letify-2.5.0}/tests/conftest.py +1 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_distributed.py +171 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_kaggle.py +73 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_runtime.py +241 -0
- {letify-2.4.7 → letify-2.5.0}/.github/workflows/ci.yml +0 -0
- {letify-2.4.7 → letify-2.5.0}/.github/workflows/docs.yml +0 -0
- {letify-2.4.7 → letify-2.5.0}/.github/workflows/publish.yml +0 -0
- {letify-2.4.7 → letify-2.5.0}/.gitignore +0 -0
- {letify-2.4.7 → letify-2.5.0}/.researchtree +0 -0
- {letify-2.4.7 → letify-2.5.0}/LICENSE +0 -0
- {letify-2.4.7 → letify-2.5.0}/README.md +0 -0
- {letify-2.4.7 → letify-2.5.0}/docs/guide/01-getting-started.md +0 -0
- {letify-2.4.7 → letify-2.5.0}/docs/guide/02-providers.md +0 -0
- {letify-2.4.7 → letify-2.5.0}/docs/guide/03-execution-modes.md +0 -0
- {letify-2.4.7 → letify-2.5.0}/docs/guide/04-environments-and-data.md +0 -0
- {letify-2.4.7 → letify-2.5.0}/docs/guide/05-concurrency.md +0 -0
- {letify-2.4.7 → letify-2.5.0}/docs/guide/06-cost.md +0 -0
- {letify-2.4.7 → letify-2.5.0}/docs/guide/07-troubleshooting.md +0 -0
- {letify-2.4.7 → letify-2.5.0}/docs/guide/08-releasing.md +0 -0
- {letify-2.4.7 → letify-2.5.0}/docs/guide/README.md +0 -0
- {letify-2.4.7 → letify-2.5.0}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
- {letify-2.4.7 → letify-2.5.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.4.7 → letify-2.5.0}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
- {letify-2.4.7 → letify-2.5.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.4.7 → letify-2.5.0}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
- {letify-2.4.7 → letify-2.5.0}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-2.4.7 → letify-2.5.0}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
- {letify-2.4.7 → letify-2.5.0}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-2.4.7 → letify-2.5.0}/docs/guide/ko/README.md +0 -0
- {letify-2.4.7 → letify-2.5.0}/docs/locales/README_ko.md +0 -0
- {letify-2.4.7 → letify-2.5.0}/docs/mkdocs.yml +0 -0
- {letify-2.4.7 → letify-2.5.0}/examples/README.md +0 -0
- {letify-2.4.7 → letify-2.5.0}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-2.4.7 → letify-2.5.0}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/examples/nvfp4-lora/README.md +0 -0
- {letify-2.4.7 → letify-2.5.0}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-2.4.7 → letify-2.5.0}/examples/nvfp4-lora/common.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/cli.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/config/__init__.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/config/inventory.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/config/login.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/config/schema.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/config/secrets.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/config/writer.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/declare/__init__.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/declare/cache.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/declare/env.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/declare/instance.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/declare/replicas.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/install.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/launcher.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/paths.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/protocol/__init__.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/protocol/codec.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/protocol/driver.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/protocol/handle.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/providers/__init__.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/providers/base.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/providers/colab.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/providers/colab_files.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/providers/colab_keepalive.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/providers/elice.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/providers/kaggle_adapter.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/providers/local.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/providers/modal.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/providers/modal_adapter.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/providers/naming.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/providers/shell.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/providers/tunnel.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/providers/usage.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/remoting/__init__.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/remoting/capability.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/remoting/device/__init__.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/remoting/device/client.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/remoting/device/cuda.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/remoting/device/executor.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/remoting/device/frames.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/remoting/device/guard.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/remoting/device/tensor.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/remoting/device/trace.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/remoting/device/value.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/remoting/loader.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/remoting/probe.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/render.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/runtime/__init__.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/runtime/lease.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/runtime/pool.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/runtime/telemetry.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/store/__init__.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/store/backends/__init__.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/store/backends/filesystem.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/store/backends/google_auth.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/store/backends/layout.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/store/backends/objects.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/store/cas.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/store/pathdata.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/store/sendorder.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/stubs.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/tools.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/transport/__init__.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/transport/agent.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/transport/announce.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/transport/link.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/transport/nat.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/transport/pipeline.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/transport/portmap.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/transport/probe.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/transport/quic.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/transport/rendezvous.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/transport/setup.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/transport/sshopts.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify/transport/strategies.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-core/.gitignore +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-core/build.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-core/letify-quic/Cargo.toml +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-core/letify-quic/src/main.rs +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-core/rust-toolchain.toml +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-ext/.vscodeignore +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-ext/LICENSE +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-ext/README.md +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-ext/media/icon.svg +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-ext/src/cli.ts +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-ext/src/extension.ts +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-ext/src/history.ts +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-ext/src/model.ts +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-ext/src/poll.ts +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-ext/src/render.ts +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-ext/test/cli.test.ts +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-ext/test/manifest.test.ts +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-ext/test/model.test.ts +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-ext/test/regressions.test.ts +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify-ext/tsconfig.json +0 -0
- {letify-2.4.7 → letify-2.5.0}/letify_providers.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/scripts/sync-main.sh +0 -0
- {letify-2.4.7 → letify-2.5.0}/scripts/version.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/fake_kaggle_adapter.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/fake_modal_adapter.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_cli.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_cli_output.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_colab_keepalive.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_config.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_core.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_data.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_data_streaming.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_declare.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_device.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_editor_sessions.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_elice.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_frames.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_install.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_kaggle_cookie.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_kaggle_link.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_link_log.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_notebook.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_parallel_placement.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_paths.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_portmap.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_protocol.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_providers.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_quic.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_remoting.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_render.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_ship.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_store.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_strategies.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_stubs.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_transport.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_tunnel_setup.py +0 -0
- {letify-2.4.7 → letify-2.5.0}/tests/test_version.py +0 -0
letify-2.5.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
Guidance for AI agents working in this repository. Read this before writing code, documentation or commits.
|
|
4
|
+
|
|
5
|
+
## Hard rules
|
|
6
|
+
|
|
7
|
+
These are not preferences. Breaking one means the change gets reverted.
|
|
8
|
+
|
|
9
|
+
1. **Never add a co-author trailer.** Do not add `Co-Authored-By` lines for Claude or any other agent. Commits are authored by the repository owner alone.
|
|
10
|
+
2. **Never use an em-dash.** Not in code, comments, docstrings, documentation, commit messages or pull request bodies. Use a hyphen, a comma, a colon or a second sentence instead. This applies to the en-dash used as punctuation as well.
|
|
11
|
+
3. **Code comments and docstrings are English only.** No exceptions, including in files whose documentation is Korean. Identifiers, log messages and error strings are English too.
|
|
12
|
+
4. **Commit subjects use a capitalized type prefix followed by a colon.** For example `Feat: Add the Colab provider`, `Fix: Release the session lock when exec raises`, `Docs: Describe the blob store layout`. Use the imperative mood and name the thing that changed. Allowed prefixes: `Feat`, `Fix`, `Docs`, `Refactor`, `Test`, `Perf`, `Chore`, `Build`.
|
|
13
|
+
5. **Never commit secrets.** Access tokens, SSH private keys and account credentials belong in the environment or in `~/.letify/accounts`, never in tracked files. The project's `.letify/` holds project defaults only.
|
|
14
|
+
|
|
15
|
+
## How this project is built
|
|
16
|
+
|
|
17
|
+
Spec driven and test driven, in that order. The sequence for any change that is not a
|
|
18
|
+
typo fix is: settle the spec, write the test that would prove it, then write the code.
|
|
19
|
+
|
|
20
|
+
### Spec first
|
|
21
|
+
|
|
22
|
+
`docs/SPEC.md` is the single source of truth for what the system does. Code follows it,
|
|
23
|
+
not the other way round.
|
|
24
|
+
|
|
25
|
+
1. **Change the spec before the code.** If a change alters behaviour, edit the section of
|
|
26
|
+
`docs/SPEC.md` that describes it first. If no section describes it, add one. A change
|
|
27
|
+
with no spec entry has nowhere to be checked against.
|
|
28
|
+
2. **A spec change is a hypothesis, not a settled requirement.** In research, whether a
|
|
29
|
+
design is right is decided by the experiment. So a design changing experiment edits the
|
|
30
|
+
spec on its own branch, and the verdict on the pull request, adopted or rejected, is
|
|
31
|
+
what decides whether that spec change reaches `develop`.
|
|
32
|
+
3. **The spec records decisions only.** What the system does, the formula it uses, the
|
|
33
|
+
value chosen. Derivations, measurement tables and comparisons go in the pull request
|
|
34
|
+
body, and the spec links to it.
|
|
35
|
+
4. **`docs/INTENT.md` sits above the spec.** It holds the claims the design is meant to
|
|
36
|
+
prove, with stable ids (`N1`, `N2`, ...). An experiment lists the claim it tests. When
|
|
37
|
+
evidence contradicts a claim, say so and propose the edit rather than leaving it
|
|
38
|
+
standing.
|
|
39
|
+
|
|
40
|
+
### Test driven
|
|
41
|
+
|
|
42
|
+
A test is written to state the expected behaviour before the code that satisfies it
|
|
43
|
+
exists. The cycle is red, then green, then tidy.
|
|
44
|
+
|
|
45
|
+
1. **Write the failing test first.** It names the behaviour, so the name reads as a
|
|
46
|
+
sentence: `test_a_handle_from_another_runtime_is_refused`. Run it and watch it fail for
|
|
47
|
+
the reason you expect, because a test that passes before the code is written is testing
|
|
48
|
+
nothing.
|
|
49
|
+
2. **Write the smallest code that makes it pass.** Then clean up with the test as the net.
|
|
50
|
+
3. **Every test traces to a spec section.** If you cannot say which section a test is
|
|
51
|
+
pinning, either the spec is missing an entry or the test is asserting an accident of the
|
|
52
|
+
implementation. Both are worth fixing.
|
|
53
|
+
4. **A bug gets a failing test before a fix.** That is how it is shown to be real, and how
|
|
54
|
+
it stays fixed.
|
|
55
|
+
5. **Exercise the real code path.** The `Local` provider starts the same worker behind the
|
|
56
|
+
same framed protocol a remote runtime uses, so anything testable through it should be.
|
|
57
|
+
A fake belongs only where the real thing needs a live account, a network or a GPU, and
|
|
58
|
+
it goes in `tests/conftest.py` rather than being written twice.
|
|
59
|
+
6. **Coverage is a smoke detector, not a goal.** A line with no test is a question to ask.
|
|
60
|
+
Chasing a percentage with tests that assert nothing makes the suite worse, and a line
|
|
61
|
+
that genuinely needs a live service is marked `# pragma: no cover` with the reason.
|
|
62
|
+
|
|
63
|
+
### What this rules out
|
|
64
|
+
|
|
65
|
+
- Writing the implementation first and the tests afterwards to match whatever it happened
|
|
66
|
+
to do. That blesses accidents as behaviour.
|
|
67
|
+
- Changing behaviour without touching the spec. The next reader then has two sources of
|
|
68
|
+
truth that disagree.
|
|
69
|
+
- Deleting or loosening a test to make a change pass. If the test was wrong, say why in
|
|
70
|
+
the commit; if the behaviour changed on purpose, the spec changes with it.
|
|
71
|
+
|
|
72
|
+
## Writing style
|
|
73
|
+
|
|
74
|
+
Everything written here is read by people first. Documentation, pull request bodies and commit messages follow the same rules.
|
|
75
|
+
|
|
76
|
+
- Lead with the conclusion, then the evidence.
|
|
77
|
+
- One idea per sentence. If a sentence needs a second read, split it.
|
|
78
|
+
- Name the concrete thing: the function, the file, the setting, the number. No metaphors and no personification.
|
|
79
|
+
- Numbers carry units and a comparison. Not `latency 150` but `round trip 150 ms, measured from Seoul to a Colab runtime in the United States`.
|
|
80
|
+
- Define every label the first time it appears.
|
|
81
|
+
- Keep it as short as it can be while still complete.
|
|
82
|
+
|
|
83
|
+
## Documentation layout
|
|
84
|
+
|
|
85
|
+
| File | Holds |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `README.md` | What letify is and how to use it, for a first-time reader |
|
|
88
|
+
| `docs/locales/README_ko.md` | Korean translation of `README.md`, kept in sync |
|
|
89
|
+
| `PROJECT.md` | The feature set and the public API surface |
|
|
90
|
+
| `docs/INTENT.md` | Problem, goals, claims (`N1`, `N2`, ...), constraints, non-goals, open decisions |
|
|
91
|
+
| `docs/SPEC.md` | The current design. Decisions only, one feature per section, each section opening with a one-line summary |
|
|
92
|
+
| `docs/COMPONENT.md` | Class relationships, responsibilities and the vocabulary of the project |
|
|
93
|
+
| `docs/NETWORK.md` | Transport paths, measured latency and throughput, tunnel selection |
|
|
94
|
+
| `docs/guide/` | Task-oriented guides, English in `docs/guide/`, Korean in `docs/guide/ko/` |
|
|
95
|
+
|
|
96
|
+
Rules for these documents:
|
|
97
|
+
|
|
98
|
+
- `docs/SPEC.md` records only the current state. No "previously we used", no "tried and rejected". History lives in Git and in pull request bodies.
|
|
99
|
+
- Derivations, measurement tables and comparisons go in the experiment pull request. The spec links to it.
|
|
100
|
+
- Section titles name features and stay stable. Before renaming one, give the heading an id comment so its history continues.
|
|
101
|
+
- Keep the English and Korean documentation in step. A change to one is incomplete until the other matches.
|
|
102
|
+
|
|
103
|
+
## ResearchTree conventions
|
|
104
|
+
|
|
105
|
+
This repository uses [ResearchTree](https://darkpyonix.github.io/researchtree/) for performance work. One branch is one experiment and one pull request is its lab note.
|
|
106
|
+
|
|
107
|
+
- Root branch: `develop`. Experiment branch prefix: `feat/`. Both are set in `.researchtree`.
|
|
108
|
+
- Never open an experiment pull request against `main`.
|
|
109
|
+
- One hypothesis per branch. Two ideas are two branches.
|
|
110
|
+
- Give each experiment its own `git worktree`. Do not switch branches in a checkout that has a job running.
|
|
111
|
+
- The first YAML block at the top of the pull request body is the record. Keep it valid and first.
|
|
112
|
+
- Reuse existing metric keys so experiments stay comparable: `efficiency_pct`, `rtt_ms`, `step_s`, `syncs_per_step`, `setup_s`, `transfer_mbps`.
|
|
113
|
+
- Do not merge, close, tag or push to `develop` unless the owner asks.
|
|
114
|
+
|
|
115
|
+
## Code layout
|
|
116
|
+
|
|
117
|
+
The Python package is pure Python and links no Python extension. The package lives in `letify/` at the repository root. letify-core, the Rust workspace in `letify-core/`, is built in CI and shipped as plain binaries in `letify/remoting/lib/` inside platform wheels.
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
letify/
|
|
121
|
+
__init__.py Public surface. Everything a user needs is re-exported here
|
|
122
|
+
errors.py Exception hierarchy
|
|
123
|
+
launcher.py Launcher, the public entry point
|
|
124
|
+
cli.py The letify command line
|
|
125
|
+
stubs.py Provider type stubs for editor completion
|
|
126
|
+
config/ .letify/config.toml loading, schema, login and credential files
|
|
127
|
+
declare/ Env, Instance and the @let.function callable
|
|
128
|
+
protocol/ Codec, framing, reference types, the remote worker and driver
|
|
129
|
+
runtime/ Channel, session, pool, lease, bootstrap and telemetry
|
|
130
|
+
store/ Content addressed blob store, volumes and backends
|
|
131
|
+
providers/ Provider base class and one module per provider
|
|
132
|
+
remoting/ CUDA API forwarding, used only where a low-latency path exists
|
|
133
|
+
_vendor/ Vendored third party code
|
|
134
|
+
letify-ext/ VS Code extension (TypeScript) showing quota and GPU activity from the CLI's --json output
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Conventions:
|
|
138
|
+
|
|
139
|
+
- Every module has a docstring saying what it owns and what it does not.
|
|
140
|
+
- Type hints on every public function. `from __future__ import annotations` at the top of each module.
|
|
141
|
+
- No provider-specific import at package import time. A missing optional dependency disables that provider and nothing else.
|
|
142
|
+
- Errors distinguish infrastructure failure from user code failure. Infrastructure failure may be retried, user code failure never is.
|
|
143
|
+
- Never fall back to local execution silently. Raise instead.
|
|
144
|
+
|
|
145
|
+
## Versions and releases
|
|
146
|
+
|
|
147
|
+
There is one version for the Python package, letify-core, the VS Code extension and the tag. `pyproject.toml` `[project].version` is the source of truth. Change it only with `python scripts/version.py set <version>`, commit that as `Chore: Set the version to <version>`, and tag `v<version>`. CI fails when any file or the tag disagrees. The files and the release workflow are in `docs/SPEC.md`, Versioning and releases, and the steps in `docs/guide/08-releasing.md`.
|
|
148
|
+
|
|
149
|
+
## Dependencies
|
|
150
|
+
|
|
151
|
+
There is one install and no extras:
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
uv add letify
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
letify installs cloudpickle and blake3 only, because it lives inside researchers' repositories. Provider tools run out of process through uv, never in the user's `.venv`: Colab through `uv tool run --from google-colab-cli colab`, Modal through a separate uv environment. Elice and the GCS blob store use the standard library HTTP client. Do not add a dependency to `pyproject.toml`.
|
letify-2.5.0/PROJECT.md
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# PROJECT
|
|
2
|
+
|
|
3
|
+
> The feature set and the public API of letify. For why each decision is what it is, see [docs/SPEC.md](docs/SPEC.md); for the vocabulary, see [docs/COMPONENT.md](docs/COMPONENT.md).
|
|
4
|
+
|
|
5
|
+
## What letify is
|
|
6
|
+
|
|
7
|
+
A declarative infrastructure library for Python. You declare what a function needs and where it belongs, and letify arranges the session, the environment, the data and the teardown.
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import letify
|
|
11
|
+
|
|
12
|
+
let = letify.Launcher()
|
|
13
|
+
colab = let.providers.colab_a
|
|
14
|
+
|
|
15
|
+
@let.function(device=colab.G4, host="remote")
|
|
16
|
+
def train(lr, bs):
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
train(lr=1e-4, bs=32)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
### Placements, no mechanisms
|
|
25
|
+
|
|
26
|
+
A declaration says where the accelerator is and where the host code runs. It never names a transport or a mode. How long a session lives is the `keep_alive` block around the calls.
|
|
27
|
+
|
|
28
|
+
| Argument | Says | Default |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `device` | which provider, account, accelerator and how many of it | required |
|
|
31
|
+
| `host` | where the host code runs: `"local"` or `"remote"` | `"local"` |
|
|
32
|
+
|
|
33
|
+
Core count and memory are not arguments. They arrive with the shape the provider registered, and a provider offering several sizes registers them as separate shapes. `lab.A100 * 2` is the same shape taking two cards.
|
|
34
|
+
|
|
35
|
+
There is no width argument and no session ceiling. A provider entry declares what the account has, and that inventory is the only thing bounding how much runs at once:
|
|
36
|
+
|
|
37
|
+
```toml
|
|
38
|
+
[lab_a100.devices]
|
|
39
|
+
A100 = { indices = "0-3" } # four cards in a shared box are ours
|
|
40
|
+
|
|
41
|
+
[colab_pro.devices]
|
|
42
|
+
G4 = { count = 2 } # two concurrent sessions on this account
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`indices` is for a machine letify shares with other people: it takes only those registered indices that are actually free when a session starts, and sets the session's visible devices so the code inside sees its cards as 0 upward. `count` is for a provider that assigns the device itself.
|
|
46
|
+
|
|
47
|
+
### Providers
|
|
48
|
+
|
|
49
|
+
Four kinds, three of them remote. A provider object is one account on one kind of infrastructure, declared once in a configuration file and reached by alias.
|
|
50
|
+
|
|
51
|
+
| Provider | Kind | Storage | Session created by |
|
|
52
|
+
|---|---|---|---|
|
|
53
|
+
| `Local` | `local` | persistent | a subprocess of this machine |
|
|
54
|
+
| `Modal` | `modal` | persistent | a Modal sandbox |
|
|
55
|
+
| `Colab` | `colab` | ephemeral | `colab new` through the official CLI |
|
|
56
|
+
| `Shell` | `shell` | ephemeral, overridable | SSH |
|
|
57
|
+
| `Tunnel` | `tunnel` | ephemeral, overridable | Tailscale or frp, then SSH |
|
|
58
|
+
| `Elice` | `elice` | persistent | an allocation on the Elice Cloud API |
|
|
59
|
+
|
|
60
|
+
`Shell` is the base for every machine letify reaches by running commands on it. `Colab`, `Tunnel` and `Elice` are its subclasses and differ only in how the connection is obtained.
|
|
61
|
+
|
|
62
|
+
Multiple accounts are supported for every provider. Each configuration entry is one account, and several entries of the same kind coexist, which is how a user with more than one Colab account gets more concurrent sessions.
|
|
63
|
+
|
|
64
|
+
### Persistent sessions
|
|
65
|
+
|
|
66
|
+
A session is one worker process kept alive behind a framed pipe, which is what makes three things work.
|
|
67
|
+
|
|
68
|
+
A value a declared body stores with `letify.session_cache("model", load_model)` stays in the session's worker process, so a later call in the same session gets it without building it again. Each session has its own cache, and a one-shot channel keeps nothing between calls. A large argument is hashed and offered by digest, so the same tensor passed to ten calls crosses the network once. Files written into a session survive between calls, so a volume can materialize an environment archive or a checkpoint.
|
|
69
|
+
|
|
70
|
+
### Session pooling
|
|
71
|
+
|
|
72
|
+
Runtimes are pooled by instance and environment, so two declarations that agree on both share a session with nothing said about it. Provider boot, environment installation and the first data transfer are all billed as GPU time, which is the reason the pool exists.
|
|
73
|
+
|
|
74
|
+
### Teardown with nothing to call
|
|
75
|
+
|
|
76
|
+
A call ends its own session. `with let.keep_alive():` keeps sessions for the length of a block, and they end when the outermost block exits. Nothing ends a session on a timer, because a timer would overrule the block that asked to keep it.
|
|
77
|
+
|
|
78
|
+
A call whose devices cannot be allocated raises `letify.InsufficientDevices` at once instead of waiting. It waits only while a session in this process that holds that accelerator is serving a call.
|
|
79
|
+
|
|
80
|
+
A heartbeat lease covers the one case nothing else can: a process killed outright says nothing to anybody. The worker exits on its own, which frees the card. Whether that also stops the billing depends on what the provider charges for, and letify states that per provider rather than implying a guarantee it cannot make. See [docs/SPEC.md](docs/SPEC.md).
|
|
81
|
+
|
|
82
|
+
There is no release call, no shutdown call and no detached mode. Durability comes from checkpoints in the store rather than from a session that outlives you.
|
|
83
|
+
|
|
84
|
+
### Content addressed storage
|
|
85
|
+
|
|
86
|
+
A volume is a blob store where contents are named by their hash and mutable names live in a separate namespace of refs. Concurrent sessions cannot overwrite each other, a transfer that already happened is skipped by name, and a tree of small files is packed into one archive so that thousands of round trips become one.
|
|
87
|
+
|
|
88
|
+
Backends are `filesystem`, `gcs` and `modal`, chosen by the provider rather than stated in the declaration.
|
|
89
|
+
|
|
90
|
+
### Concurrency from repeated calls
|
|
91
|
+
|
|
92
|
+
Many configurations are many calls. Inside `with let.keep_alive():`, `asyncio.gather` over an `async def` declaration runs them at once and returns results in call order, and `asyncio.as_completed` yields them as they finish. The inventory bounds how many run; a call beyond it waits for a card.
|
|
93
|
+
|
|
94
|
+
### Environment from a uv lock file
|
|
95
|
+
|
|
96
|
+
`Env` is keyed by the hash of `uv.lock`. One lock file resolves for every platform, so a Windows or macOS client drives a Linux runtime. Packages in the lock file are installed remotely by name; the project's own code travels with the call.
|
|
97
|
+
|
|
98
|
+
### PyTorch forwarding
|
|
99
|
+
|
|
100
|
+
`host="local"` keeps Python, the data and the libraries here and runs PyTorch operators on the runtime's GPU, through PyTorch's own `__torch_dispatch__` extension point. Code written for `"cuda"` runs unchanged, with any local PyTorch build including a CPU one. Operators are queued and sent in batches and only a read of a value waits for the runtime, so the round trip count is the number of host synchronizations rather than the number of operators. `letify.remoting.device.current_client().stats` reports both counts from inside the function.
|
|
101
|
+
|
|
102
|
+
## Public API
|
|
103
|
+
|
|
104
|
+
Everything a user needs is on `letify` itself.
|
|
105
|
+
|
|
106
|
+
### `letify.Launcher`
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
Launcher(
|
|
110
|
+
config=None, # a .letify directory or its config.toml; defaults to the project and home ones
|
|
111
|
+
name=None, # session and app name; defaults to the pyproject project name
|
|
112
|
+
stream_logs=True, # write remote stdout and stderr live to the local ones
|
|
113
|
+
announce=True, # say when a session starts, because that is when money starts
|
|
114
|
+
home=True, # read ~/.letify/config.toml
|
|
115
|
+
)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
| Member | Does |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `let.providers.<alias>` | Return the provider declared under that alias |
|
|
121
|
+
| `let.providers.any.<DEVICE>` | Request an accelerator without naming a provider |
|
|
122
|
+
| `let.providers.devices` | Accelerators every provider offers |
|
|
123
|
+
| `let.providers.active` | Providers that currently hold a session |
|
|
124
|
+
| `let.providers.aliases` | Declared aliases, in configuration order |
|
|
125
|
+
| `let.provider(alias)` | Same as attribute access, for a computed alias |
|
|
126
|
+
| `let.function(...)` | Declare a function, returning a decorator |
|
|
127
|
+
| `with let.keep_alive():` | Keep sessions alive between calls for the length of the block |
|
|
128
|
+
| `let.status()` | How many sessions are live and busy against the ceiling, and what each one is |
|
|
129
|
+
| `let.usage(alias=None)` | What is left on each account, or why it is not reported |
|
|
130
|
+
| `let.utilization(alias=None)` | How busy each instance's accelerator is right now |
|
|
131
|
+
|
|
132
|
+
### `let.function`
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
@let.function(
|
|
136
|
+
device=colab.G4, # an Instance, carrying provider, account and accelerator
|
|
137
|
+
host="remote", # letify.local forwards PyTorch operators, letify.remote ships the function
|
|
138
|
+
env=env, # an Env; defaults to Env()
|
|
139
|
+
volumes=[cache], # volumes to attach
|
|
140
|
+
timeout=None, # seconds one call may take; no default deadline
|
|
141
|
+
retries=1, # retries on infrastructure failure, never on user code failure
|
|
142
|
+
)
|
|
143
|
+
def train(lr, bs): ...
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Sync or async is taken from the `def`. A plain `def` blocks; an `async def` returns a plain coroutine.
|
|
147
|
+
|
|
148
|
+
| Call form | Returns |
|
|
149
|
+
|---|---|
|
|
150
|
+
| `train(lr=1e-4)` on a `def` | the value |
|
|
151
|
+
| `await train(lr=1e-4)` on an `async def` | the value |
|
|
152
|
+
| `await asyncio.gather(*(train(lr=x) for x in lrs))` | a list, in call order |
|
|
153
|
+
| `train.local(lr=1e-4)` | runs the body in this process |
|
|
154
|
+
|
|
155
|
+
### `letify.Env`
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
env = letify.Env() # uv.lock
|
|
159
|
+
env = letify.Env.from_lock("other.lock")
|
|
160
|
+
env = env.pip_install("flash-attn") # packages the lock file does not carry
|
|
161
|
+
env = env.run("apt-get install -y git") # commands after installation
|
|
162
|
+
env = env.vars(HF_HOME="/opt/cache") # environment variables in the session
|
|
163
|
+
env = env.ship("mypkg") # send this module by value, overriding inference
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Instances
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
colab.G4 # registered shape
|
|
170
|
+
colab.device("G4") # same, for a computed name
|
|
171
|
+
colab.instances # everything this account offers
|
|
172
|
+
colab.refresh() # ask the provider again
|
|
173
|
+
let.providers.any.A100 # deferred provider choice
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Colab accepts `T4`, `L4`, `G4`, `A100`, `H100`, plus the TPUs `v5e1` and `v6e1`. `G4` is the RTX PRO 6000 Blackwell part, and `RTX_PRO_6000` is accepted as an alias for it.
|
|
177
|
+
|
|
178
|
+
### Volumes
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
cache = colab.volume("hf-cache") # backend from the provider
|
|
182
|
+
cache = colab.volume("hf-cache", bucket="my-bucket")
|
|
183
|
+
|
|
184
|
+
cache.cached_env(env) # digest of a prebuilt environment archive
|
|
185
|
+
cache.cache_env(env, "/opt/venv") # pack one from this machine
|
|
186
|
+
cache.cache_env_from(runtime, env, path) # pack one installed inside a session
|
|
187
|
+
cache.put_checkpoint("run-1", path) # store and move the ref
|
|
188
|
+
cache.absorb(runtime, path, "run-1") # pull one out of a session
|
|
189
|
+
cache.fetch_checkpoint("run-1", target)
|
|
190
|
+
cache.resume(runtime, "run-1", path) # put the newest one inside a session
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Types
|
|
194
|
+
|
|
195
|
+
| Name | Is |
|
|
196
|
+
|---|---|
|
|
197
|
+
| `Launcher`, `Providers` | the entry point and its provider view |
|
|
198
|
+
| `Instance`, `AnyInstance` | an accelerator shape, and a deferred one |
|
|
199
|
+
| `local`, `remote` | the two values `host` takes |
|
|
200
|
+
| `Env` | an environment declaration |
|
|
201
|
+
| `Volume` | a content addressed store on a provider |
|
|
202
|
+
| `session_cache` | a value built once per session, keyed by name |
|
|
203
|
+
| `Blob`, `RemoteFile` | references to something that lives in a session |
|
|
204
|
+
| `Function` | what the decorator returns |
|
|
205
|
+
|
|
206
|
+
### Errors
|
|
207
|
+
|
|
208
|
+
| Error | Means | Retryable |
|
|
209
|
+
|---|---|---|
|
|
210
|
+
| `ConfigError` | The configuration file is malformed or names an unknown kind | no |
|
|
211
|
+
| `ProviderUnavailable` | A provider's optional dependency or setting is missing | no |
|
|
212
|
+
| `UnknownProvider`, `UnknownInstance` | No such alias or accelerator | no |
|
|
213
|
+
| `RuntimeFailure`, `RuntimeLost` | The session misbehaved | yes |
|
|
214
|
+
| `ProtocolError` | The remote process died before producing a result | yes |
|
|
215
|
+
| `RemoteError` | The shipped function raised; carries the remote traceback | no |
|
|
216
|
+
| `UnsupportedMode` | The requested mode cannot work here | no |
|
|
217
|
+
| `InsufficientDevices` | The devices a call asks for cannot be allocated; names what holds them | no |
|
|
218
|
+
|
|
219
|
+
### Command line
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
letify login <kind> [alias] # declare an account at home, reference it here
|
|
223
|
+
letify logout <alias> # remove the account from this machine
|
|
224
|
+
letify providers # declared providers, storage, channel kind
|
|
225
|
+
letify devices # accelerators each provider offers
|
|
226
|
+
letify status # live sessions and what they are costing
|
|
227
|
+
letify usage [alias] # what is left on each account
|
|
228
|
+
letify utilization [alias] # how busy each instance's accelerator is
|
|
229
|
+
letify check <alias> # confirm a machine answers
|
|
230
|
+
letify probe <host> # whether host="local" is worth using
|
|
231
|
+
letify efficiency 0.5 3 150 # expected share of a direct run
|
|
232
|
+
letify stubs # write typings/letify_providers.pyi for editor completion
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Configuration
|
|
236
|
+
|
|
237
|
+
Configuration is a directory. `~/.letify/config.toml` holds accounts, the project's `.letify/config.toml` holds defaults and the aliases it uses, and neither holds a secret.
|
|
238
|
+
|
|
239
|
+
An account in `~/.letify/config.toml` is available in a project only when the project's `.letify/config.toml` names its alias, even as an empty `[colab_pro]` table, when the home entry sets `global = true`, or when it is `local`. A named alias takes every home setting and the project's own fields override them one by one. With no project `.letify/` at all, only global accounts and `local` exist.
|
|
240
|
+
|
|
241
|
+
`letify login <kind> [alias]` writes both. The account goes to `~/.letify/config.toml`, which belongs to the machine, and any credential it collects goes to `~/.letify/accounts/<alias>/`. `letify logout <alias>` deletes that account directory. The project file gets the alias as an empty table and nothing else, which is safe to commit and tells a teammate which accounts the repository needs. An account already declared is not asked for again, so `letify login` in a second repository writes only the table. An alias the project names that this machine does not have is a configuration error naming the command that fixes it.
|
|
242
|
+
|
|
243
|
+
SSH authenticates by key, because letify opens sessions with `ssh -o BatchMode=yes`: a session is started by the pool in the background, with nobody present to answer a password prompt. So `letify login shell` generates an ed25519 key if there is none, asks for the password once to install it, drops the password, and confirms the key works before declaring the alias. It then runs `nvidia-smi` on the machine once, asks which indices of each accelerator letify may use, and writes them to `[<alias>.devices]` in the home file, so editor completion knows the names. `--indices NAME=SPEC` chooses them in a script, `--detect-devices` asks an already declared machine again, and a machine where `nvidia-smi` does not answer is logged in with no table. Nothing about the password is written to a file or the environment. A machine whose administrator forbids key authentication can use `--auth password`, which reads the password from `~/.letify/accounts/<alias>/password` and drives `sshpass`; it is refused on Windows, where that tool does not exist.
|
|
244
|
+
|
|
245
|
+
Everything letify writes on a remote machine lives under one directory per account, the workspace root: the project `.venv`, materialized volume data, temporary files and the worker's working directory. An account in `~/.letify/config.toml` sets it with `workspace = "<path>"`, a path that starts with `/` or `~`, expanded on the machine. A project file cannot set it. The default is `~/.letify-runtime` for `shell`, `tunnel` and `elice`, `/content/letify` for `colab`, and `/letify` for `modal`, where a Modal volume is mounted so the root persists across sandboxes. `letify login shell` and `letify login tunnel` ask for it, take `--workspace PATH` in a script, and check over SSH that the path can be created and written without root before anything is written. `letify check <alias>` reports whether the root is writable.
|
|
246
|
+
|
|
247
|
+
`letify login modal <alias>` runs Modal's own `modal token new` through `uv tool run`, with `MODAL_CONFIG_PATH` pointing at `~/.letify/accounts/<alias>/modal.toml`, and approves in the browser. The Modal provider and the `modal` volume backend never import `modal` in the letify process: they drive a small adapter, `letify/providers/modal_adapter.py`, run with `uv run --no-project --with "modal>=1.0,<2"` as that account and spoken to in JSON lines.
|
|
248
|
+
|
|
249
|
+
```toml
|
|
250
|
+
[defaults]
|
|
251
|
+
name = "nvfp4"
|
|
252
|
+
|
|
253
|
+
[colab_a]
|
|
254
|
+
kind = "colab"
|
|
255
|
+
account = "someone@example.com"
|
|
256
|
+
|
|
257
|
+
[lab_a100]
|
|
258
|
+
kind = "shell"
|
|
259
|
+
address = "gpu.lab.example.edu"
|
|
260
|
+
user = "researcher"
|
|
261
|
+
key = "~/.ssh/id_ed25519"
|
|
262
|
+
persistent = true
|
|
263
|
+
workspace = "/workspace/researcher/letify"
|
|
264
|
+
|
|
265
|
+
[elice_a100]
|
|
266
|
+
kind = "elice"
|
|
267
|
+
zone_id = "..."
|
|
268
|
+
price_type = "ondemand" # or "spot"; letify creates the machine through eci on first use
|
|
269
|
+
access_token_env = "ELICE_ACCESS_TOKEN"
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
An alias must be a Python identifier, because providers are reached by attribute. `any`, `devices` and `active` are reserved. Declaration order sets the priority for `let.providers.any`.
|
|
273
|
+
|
|
274
|
+
`letify usage` reads each account from its own service: Colab compute units from `ccu-info`, Elice credit in KRW from the billing API at `billing_endpoint`, Modal's month against `monthly_credit` (30 USD by default), and Kaggle GPU hours per week. A machine reached by SSH has no quota. Providers are asked at once, each waited on for `usage_timeout` seconds (20 by default). An entry can instead name a command that prints the figure: `usage_command` is run when `letify usage` asks, `usage_unit` names what it counts and `usage_limit` gives the ceiling. The last number in the output is read as the remaining amount. Without a command, `usage_limit` is the plan allowance for the provider's own reading, which gives a Colab balance a percentage. The output is one block per account: a gauge of used against the limit, the amount left, a countdown to the reset and the hourly rate, coloured only on a terminal without `NO_COLOR`.
|
|
275
|
+
|
|
276
|
+
A credential never enters `config.toml`. It is resolved in order: the environment variable named by `<name>_env`, then the file `~/.letify/accounts/<alias>/<name>` (mode 0600, written by `letify login`), then a literal value. The OS keyring is not used.
|
|
277
|
+
|
|
278
|
+
Provider types for editor completion are written to `typings/letify_providers.pyi` at the project root, automatically when the configuration loads or on demand with `letify stubs`. `[tool.letify] typings = "<path>"` in `pyproject.toml` moves it.
|
|
279
|
+
|
|
280
|
+
## Installation
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
uv add letify
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
That is the only install. `letify` installs cloudpickle and blake3 and nothing else. Provider tools run out of process through uv, never in the user's `.venv`, so uv must be installed. letify finds it from the `UV` environment variable, then `PATH`, and raises a clear error if it is absent.
|
|
287
|
+
|
|
288
|
+
`host="local"` additionally needs PyTorch 2.1 or newer in the project, locally and in the runtime's environment, with the same major.minor version on both sides. letify never installs it: torch is the project's own dependency.
|
|
289
|
+
|
|
290
|
+
## How this project is built
|
|
291
|
+
|
|
292
|
+
Spec driven and test driven. [docs/SPEC.md](docs/SPEC.md) is the source of truth, a change edits it before the code, and every test traces to a section of it. The rules are in [CLAUDE.md](CLAUDE.md).
|
|
293
|
+
|
|
294
|
+
## What is not implemented yet
|
|
295
|
+
|
|
296
|
+
Stated plainly so nobody builds on a promise. The same list, with detail, is at the end of [docs/SPEC.md](docs/SPEC.md).
|
|
297
|
+
|
|
298
|
+
- `host="local"` forwards to one device per session and does not provide CUDA streams, events, graphs or generator state. Custom CUDA extensions compiled in this process cannot run on the runtime.
|
|
299
|
+
- `Modal` and `Elice` follow each service's published interface but have not been run against the live services.
|
|
300
|
+
- Whether `ssh -L` works over `colab ssh --proxy-mode` is unverified.
|
|
301
|
+
- Persistence detection by marker file is a decision in the spec, not yet code.
|