letify 1.2.0__tar.gz → 1.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {letify-1.2.0 → letify-1.2.2}/PKG-INFO +4 -1
- {letify-1.2.0 → letify-1.2.2}/README.md +3 -0
- {letify-1.2.0 → letify-1.2.2}/docs/guide/04-environments-and-data.md +1 -1
- {letify-1.2.0 → letify-1.2.2}/docs/guide/07-troubleshooting.md +1 -1
- {letify-1.2.0 → letify-1.2.2}/docs/guide/ko/04-/355/231/230/352/262/275/352/263/274-/353/215/260/354/235/264/355/204/260.md +1 -1
- {letify-1.2.0 → letify-1.2.2}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md +1 -1
- {letify-1.2.0 → letify-1.2.2}/docs/locales/README_ko.md +3 -0
- {letify-1.2.0 → letify-1.2.2}/letify/__init__.py +1 -1
- {letify-1.2.0 → letify-1.2.2}/letify/config/login.py +39 -7
- {letify-1.2.0 → letify-1.2.2}/letify/paths.py +1 -1
- {letify-1.2.0 → letify-1.2.2}/letify/protocol/driver.py +42 -13
- {letify-1.2.0 → letify-1.2.2}/letify/protocol/worker.py +20 -21
- {letify-1.2.0 → letify-1.2.2}/letify/providers/base.py +3 -3
- {letify-1.2.0 → letify-1.2.2}/letify/providers/colab.py +40 -20
- {letify-1.2.0 → letify-1.2.2}/letify/providers/colab_keepalive.py +24 -6
- {letify-1.2.0 → letify-1.2.2}/letify/providers/kaggle.py +28 -14
- {letify-1.2.0 → letify-1.2.2}/letify/runtime/channel.py +4 -2
- {letify-1.2.0 → letify-1.2.2}/letify/runtime/session.py +20 -7
- {letify-1.2.0 → letify-1.2.2}/letify/store/pathdata.py +113 -4
- {letify-1.2.0 → letify-1.2.2}/letify/tools.py +2 -25
- {letify-1.2.0 → letify-1.2.2}/letify/transport/pipeline.py +18 -11
- {letify-1.2.0 → letify-1.2.2}/letify/transport/rendezvous.py +16 -3
- {letify-1.2.0 → letify-1.2.2}/letify-core/Cargo.lock +3 -3
- {letify-1.2.0 → letify-1.2.2}/letify-core/Cargo.toml +1 -1
- {letify-1.2.0 → letify-1.2.2}/letify-ext/package-lock.json +2 -2
- {letify-1.2.0 → letify-1.2.2}/letify-ext/package.json +1 -1
- {letify-1.2.0 → letify-1.2.2}/pyproject.toml +1 -1
- {letify-1.2.0 → letify-1.2.2}/tests/test_colab_keepalive.py +22 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_kaggle.py +178 -3
- {letify-1.2.0 → letify-1.2.2}/tests/test_paths.py +1 -1
- {letify-1.2.0 → letify-1.2.2}/tests/test_protocol.py +16 -26
- {letify-1.2.0 → letify-1.2.2}/tests/test_providers.py +153 -6
- letify-1.2.2/tests/test_ship.py +239 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_transport.py +69 -2
- {letify-1.2.0 → letify-1.2.2}/.github/workflows/ci.yml +0 -0
- {letify-1.2.0 → letify-1.2.2}/.github/workflows/docs.yml +0 -0
- {letify-1.2.0 → letify-1.2.2}/.github/workflows/publish.yml +0 -0
- {letify-1.2.0 → letify-1.2.2}/.gitignore +0 -0
- {letify-1.2.0 → letify-1.2.2}/.researchtree +0 -0
- {letify-1.2.0 → letify-1.2.2}/LICENSE +0 -0
- {letify-1.2.0 → letify-1.2.2}/docs/guide/01-getting-started.md +0 -0
- {letify-1.2.0 → letify-1.2.2}/docs/guide/02-providers.md +0 -0
- {letify-1.2.0 → letify-1.2.2}/docs/guide/03-execution-modes.md +0 -0
- {letify-1.2.0 → letify-1.2.2}/docs/guide/05-concurrency.md +0 -0
- {letify-1.2.0 → letify-1.2.2}/docs/guide/06-cost.md +0 -0
- {letify-1.2.0 → letify-1.2.2}/docs/guide/08-releasing.md +0 -0
- {letify-1.2.0 → letify-1.2.2}/docs/guide/README.md +0 -0
- {letify-1.2.0 → letify-1.2.2}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
- {letify-1.2.0 → letify-1.2.2}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
- {letify-1.2.0 → letify-1.2.2}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
- {letify-1.2.0 → letify-1.2.2}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
- {letify-1.2.0 → letify-1.2.2}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-1.2.0 → letify-1.2.2}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-1.2.0 → letify-1.2.2}/docs/guide/ko/README.md +0 -0
- {letify-1.2.0 → letify-1.2.2}/docs/mkdocs.yml +0 -0
- {letify-1.2.0 → letify-1.2.2}/examples/README.md +0 -0
- {letify-1.2.0 → letify-1.2.2}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-1.2.0 → letify-1.2.2}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/examples/nvfp4-lora/README.md +0 -0
- {letify-1.2.0 → letify-1.2.2}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-1.2.0 → letify-1.2.2}/examples/nvfp4-lora/common.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/cli.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/config/__init__.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/config/inventory.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/config/schema.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/config/secrets.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/config/writer.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/declare/__init__.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/declare/cache.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/declare/env.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/declare/function.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/declare/instance.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/errors.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/install.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/launcher.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/protocol/__init__.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/protocol/codec.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/protocol/handle.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/protocol/wire.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/providers/__init__.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/providers/colab_files.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/providers/elice.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/providers/kaggle_adapter.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/providers/local.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/providers/modal.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/providers/modal_adapter.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/providers/naming.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/providers/shell.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/providers/tunnel.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/providers/usage.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/remoting/__init__.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/remoting/capability.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/remoting/device/__init__.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/remoting/device/client.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/remoting/device/cuda.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/remoting/device/executor.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/remoting/device/frames.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/remoting/device/guard.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/remoting/device/tensor.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/remoting/device/trace.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/remoting/device/value.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/remoting/loader.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/remoting/probe.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/render.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/runtime/__init__.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/runtime/bootstrap.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/runtime/lease.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/runtime/pool.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/runtime/telemetry.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/store/__init__.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/store/backends/__init__.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/store/backends/filesystem.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/store/backends/google_auth.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/store/backends/layout.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/store/backends/objects.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/store/cas.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/store/sendorder.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/store/volume.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/stubs.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/transport/__init__.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/transport/agent.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/transport/announce.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/transport/link.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/transport/nat.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/transport/probe.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/transport/setup.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/transport/sshopts.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify/transport/strategies.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-core/.gitignore +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-core/build.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-core/rust-toolchain.toml +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-ext/.vscodeignore +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-ext/LICENSE +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-ext/README.md +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-ext/media/icon.svg +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-ext/src/cli.ts +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-ext/src/extension.ts +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-ext/src/history.ts +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-ext/src/model.ts +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-ext/src/poll.ts +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-ext/src/render.ts +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-ext/test/cli.test.ts +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-ext/test/manifest.test.ts +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-ext/test/model.test.ts +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-ext/test/regressions.test.ts +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify-ext/tsconfig.json +0 -0
- {letify-1.2.0 → letify-1.2.2}/letify_providers.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/scripts/sync-main.sh +0 -0
- {letify-1.2.0 → letify-1.2.2}/scripts/version.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/conftest.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/fake_kaggle_adapter.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/fake_modal_adapter.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_cli.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_cli_output.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_config.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_core.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_data.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_data_streaming.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_declare.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_device.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_editor_sessions.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_elice.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_frames.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_install.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_kaggle_cookie.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_link_log.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_notebook.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_remoting.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_render.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_runtime.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_store.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_strategies.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_stubs.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_tunnel_setup.py +0 -0
- {letify-1.2.0 → letify-1.2.2}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: letify
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.2
|
|
4
4
|
Summary: Declarations that become infrastructure.
|
|
5
5
|
Project-URL: Homepage, https://github.com/DarkPyonix/letify
|
|
6
6
|
Project-URL: Documentation, https://github.com/DarkPyonix/letify/tree/main/docs
|
|
@@ -192,6 +192,7 @@ Each command writes two files. The account goes to `~/.letify/config.toml`, whic
|
|
|
192
192
|
[colab_pro_plus]
|
|
193
193
|
kind = "colab"
|
|
194
194
|
account = "you@example.com"
|
|
195
|
+
min_mib_per_s = 0.05
|
|
195
196
|
|
|
196
197
|
[lab_a100]
|
|
197
198
|
kind = "shell"
|
|
@@ -201,6 +202,8 @@ key = "~/.ssh/id_ed25519"
|
|
|
201
202
|
persistent = true
|
|
202
203
|
```
|
|
203
204
|
|
|
205
|
+
The connection pipeline defaults to `10 MiB/s` in each direction and `300 ms` RTT. Colab's measured sustained best is `8.8 MiB/s` up and `10.7 MiB/s` down, so it fails the default floor. The account example explicitly permits a slow link with `min_mib_per_s = 0.05`. Set `min_mib_per_s` and `max_rtt_ms` for your measurements. When all measured links fail the floor, the connection is refused even if an unprobed fallback connected.
|
|
206
|
+
|
|
204
207
|
The project's `.letify/config.toml` gets only the alias, which is what makes the account usable in this project:
|
|
205
208
|
|
|
206
209
|
```toml
|
|
@@ -167,6 +167,7 @@ Each command writes two files. The account goes to `~/.letify/config.toml`, whic
|
|
|
167
167
|
[colab_pro_plus]
|
|
168
168
|
kind = "colab"
|
|
169
169
|
account = "you@example.com"
|
|
170
|
+
min_mib_per_s = 0.05
|
|
170
171
|
|
|
171
172
|
[lab_a100]
|
|
172
173
|
kind = "shell"
|
|
@@ -176,6 +177,8 @@ key = "~/.ssh/id_ed25519"
|
|
|
176
177
|
persistent = true
|
|
177
178
|
```
|
|
178
179
|
|
|
180
|
+
The connection pipeline defaults to `10 MiB/s` in each direction and `300 ms` RTT. Colab's measured sustained best is `8.8 MiB/s` up and `10.7 MiB/s` down, so it fails the default floor. The account example explicitly permits a slow link with `min_mib_per_s = 0.05`. Set `min_mib_per_s` and `max_rtt_ms` for your measurements. When all measured links fail the floor, the connection is refused even if an unprobed fallback connected.
|
|
181
|
+
|
|
179
182
|
The project's `.letify/config.toml` gets only the alias, which is what makes the account usable in this project:
|
|
180
183
|
|
|
181
184
|
```toml
|
|
@@ -43,7 +43,7 @@ Packages named in the lock file are installed in the runtime and referenced by n
|
|
|
43
43
|
letify infers this from the lock file. Override the inference only when you need to:
|
|
44
44
|
|
|
45
45
|
```python
|
|
46
|
-
env = env.ship("mypkg") #
|
|
46
|
+
env = env.ship("mypkg") # ship this module to the runtime
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
This is why editing code and rerunning is cheap. Code is a few hundred kilobytes; the environment and the data are the expensive parts, and they are cached.
|
|
@@ -264,7 +264,7 @@ kind = "tunnel"
|
|
|
264
264
|
mtu = 1280
|
|
265
265
|
```
|
|
266
266
|
|
|
267
|
-
letify holds every connection strategy, Tailcat's hole punch included, to a floor: a probe below
|
|
267
|
+
letify holds every connection strategy, Tailcat's hole punch included, to a floor: a probe below 10 MiB/s or above 300 ms round trip fails the connection instead of completing it, because a slow link is a failure, not a fallback. See [docs/SPEC.md, Link floor](../SPEC.md#link-floor). If the connection now fails with "below the floor", that is the diagnosis: the path is too slow to use, usually because Tailcat's own direct UDP punch did not succeed and it fell back to a relay. No relay path is offered as an alternative, so fix the underlying reachability instead: open the UDP port the punch needs, or set `address` to a direct SSH path if one exists on the same network. See [docs/NETWORK.md](../NETWORK.md).
|
|
268
268
|
|
|
269
269
|
### Gathered calls are not running in parallel
|
|
270
270
|
|
|
@@ -43,7 +43,7 @@ lock 파일에 있는 패키지는 런타임에 설치되고 이름으로 참조
|
|
|
43
43
|
letify가 lock 파일을 보고 판단합니다. 필요할 때만 덮어쓰세요.
|
|
44
44
|
|
|
45
45
|
```python
|
|
46
|
-
env = env.ship("mypkg") # 이 모듈을
|
|
46
|
+
env = env.ship("mypkg") # 이 모듈을 런타임으로 전송합니다
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
그래서 코드를 고치고 다시 돌리는 것이 쌉니다. 코드는 수백 KB이고, 비싼 것은 환경과 데이터이며 그건 캐시됩니다.
|
{letify-1.2.0 → letify-1.2.2}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md
RENAMED
|
@@ -264,7 +264,7 @@ kind = "tunnel"
|
|
|
264
264
|
mtu = 1280
|
|
265
265
|
```
|
|
266
266
|
|
|
267
|
-
letify는 Tailcat 홀펀칭을 포함한 모든 연결 전략을 바닥값에 묶어 둡니다: 측정값이
|
|
267
|
+
letify는 Tailcat 홀펀칭을 포함한 모든 연결 전략을 바닥값에 묶어 둡니다: 측정값이 10 MiB/s 미만이거나 왕복 300 ms를 넘으면 연결을 완료시키는 대신 실패시킵니다. 느린 링크는 폴백이 아니라 실패이기 때문입니다. [docs/SPEC.md의 Link floor](../../SPEC.md#link-floor)를 보세요. 이제 "below the floor"로 연결이 실패한다면 그것이 진단입니다: 경로가 너무 느려 쓸 수 없고, 보통 Tailcat의 직접 UDP 홀펀칭이 성공하지 못해 중계로 떨어졌기 때문입니다. 대안으로 중계 경로를 제공하지 않으므로, 도달성 자체를 고치세요: 홀펀칭에 필요한 UDP 포트를 열거나, 같은 망에 직접 SSH 경로가 있다면 `address`를 설정하세요. [docs/NETWORK.md](../../NETWORK.md)를 보세요.
|
|
268
268
|
|
|
269
269
|
### 모은 호출이 병렬로 돌지 않습니다
|
|
270
270
|
|
|
@@ -161,6 +161,7 @@ letify login shell lab_a100
|
|
|
161
161
|
[colab_pro_plus]
|
|
162
162
|
kind = "colab"
|
|
163
163
|
account = "you@example.com"
|
|
164
|
+
min_mib_per_s = 0.05
|
|
164
165
|
|
|
165
166
|
[lab_a100]
|
|
166
167
|
kind = "shell"
|
|
@@ -170,6 +171,8 @@ key = "~/.ssh/id_ed25519"
|
|
|
170
171
|
persistent = true
|
|
171
172
|
```
|
|
172
173
|
|
|
174
|
+
연결 파이프라인의 기본 기준은 양방향 `10 MiB/s`, RTT `300 ms`입니다. Colab의 측정된 지속 전송 최고값은 올리기 `8.8 MiB/s`, 내리기 `10.7 MiB/s`라서 기본 기준을 통과하지 못합니다. 위 계정의 `min_mib_per_s = 0.05`는 느린 연결을 명시적으로 허용하는 예시입니다. 실제 측정에 맞춰 `min_mib_per_s`와 `max_rtt_ms`를 설정하세요. 측정된 링크가 모두 기준 미달이면 측정하지 않은 fallback으로 바꾸지 않고 연결을 거부합니다.
|
|
175
|
+
|
|
173
176
|
프로젝트의 `.letify/config.toml`에는 alias만 들어갑니다. 이 줄이 있어야 그 계정을 이 프로젝트에서 쓸 수 있습니다.
|
|
174
177
|
|
|
175
178
|
```toml
|
|
@@ -756,12 +756,25 @@ def colab_account(answers: Answers) -> dict[str, Any]:
|
|
|
756
756
|
KAGGLE_COOKIE = "cookie"
|
|
757
757
|
KAGGLE_COOKIE_PROMPT = "Kaggle cookie (from a logged-in kaggle.com tab): "
|
|
758
758
|
|
|
759
|
+
#: What each credential prompt adds on a renewal, where a blank answer keeps the stored
|
|
760
|
+
#: one. Spec "Kaggle account".
|
|
761
|
+
KAGGLE_KEEP_SUFFIX = "[blank keeps the stored one] "
|
|
759
762
|
|
|
760
|
-
|
|
761
|
-
|
|
763
|
+
|
|
764
|
+
def read_kaggle_cookie(answers: Answers, given: str | None, stored: str | None = None) -> str:
|
|
765
|
+
"""Return the cookie string, from the value given, a file it names, or a prompt.
|
|
766
|
+
|
|
767
|
+
On a renewal the prompt says a blank answer keeps ``stored``, so the cookie, which is a
|
|
768
|
+
thirty day session, can be replaced without repasting the token. Spec "Kaggle account".
|
|
769
|
+
"""
|
|
762
770
|
text = (given or "").strip()
|
|
763
771
|
if not text and answers.interactive:
|
|
764
|
-
|
|
772
|
+
prompt = KAGGLE_COOKIE_PROMPT
|
|
773
|
+
if stored:
|
|
774
|
+
prompt = f"{KAGGLE_COOKIE_PROMPT}{KAGGLE_KEEP_SUFFIX}"
|
|
775
|
+
text = read_password(prompt).strip()
|
|
776
|
+
if not text and stored:
|
|
777
|
+
return stored
|
|
765
778
|
if not text:
|
|
766
779
|
raise LoginError(
|
|
767
780
|
f"{answers.alias} needs the Kaggle cookie of a logged-in kaggle.com tab. "
|
|
@@ -794,9 +807,15 @@ def read_kaggle_token(
|
|
|
794
807
|
username = read_line(KAGGLE_OWNER_PROMPT).strip()
|
|
795
808
|
from ..providers.kaggle import read_api_token
|
|
796
809
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
810
|
+
stored = read_api_token(answers.alias)
|
|
811
|
+
key = given_key or ""
|
|
812
|
+
if not key.strip() and answers.interactive:
|
|
813
|
+
prompt = KAGGLE_TOKEN_PROMPT
|
|
814
|
+
if stored:
|
|
815
|
+
prompt = f"{KAGGLE_TOKEN_PROMPT}{KAGGLE_KEEP_SUFFIX}"
|
|
816
|
+
key = read_password(prompt)
|
|
817
|
+
if not key.strip() and stored:
|
|
818
|
+
key = stored
|
|
800
819
|
if not username or not key:
|
|
801
820
|
raise LoginError(
|
|
802
821
|
f"{answers.alias} needs the Kaggle API token and notebook owner: pass "
|
|
@@ -827,7 +846,11 @@ def kaggle_account(answers: Answers) -> dict[str, Any]:
|
|
|
827
846
|
|
|
828
847
|
username, key = read_kaggle_token(answers, answers.get("username"), answers.get("key"))
|
|
829
848
|
given = answers.get(KAGGLE_COOKIE) or answers.token
|
|
830
|
-
cookie = read_kaggle_cookie(
|
|
849
|
+
cookie = read_kaggle_cookie(
|
|
850
|
+
answers,
|
|
851
|
+
given if isinstance(given, str) else None,
|
|
852
|
+
stored=stored_secret(answers.alias, KAGGLE_COOKIE),
|
|
853
|
+
)
|
|
831
854
|
try:
|
|
832
855
|
kg.require_cookie_shape(cookie)
|
|
833
856
|
expiry = kg.cookie_expiry(cookie)
|
|
@@ -954,6 +977,15 @@ def store_secret(alias: str, name: str, secret: str) -> None:
|
|
|
954
977
|
secrets.write_secret(alias, name, secret)
|
|
955
978
|
|
|
956
979
|
|
|
980
|
+
def stored_secret(alias: str, name: str) -> str | None:
|
|
981
|
+
"""The credential already in the account directory, or None when there is none to keep."""
|
|
982
|
+
try:
|
|
983
|
+
value = (secrets.account_directory(alias) / name).read_text(encoding="utf-8").strip()
|
|
984
|
+
except (OSError, UnicodeError):
|
|
985
|
+
return None
|
|
986
|
+
return value or None
|
|
987
|
+
|
|
988
|
+
|
|
957
989
|
def forget_secret(alias: str) -> bool:
|
|
958
990
|
"""Delete everything this machine holds for an account. Returns whether anything was there."""
|
|
959
991
|
return secrets.forget_account(alias)
|
|
@@ -13,12 +13,14 @@ is no blob table, so every large argument travels again on every call.
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
import base64
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from pathlib import Path
|
|
16
18
|
from typing import Any
|
|
17
19
|
|
|
18
20
|
from .codec import BEGIN, END, PROTOCOL_VERSION, dumps_call
|
|
19
21
|
|
|
20
22
|
_TEMPLATE = """
|
|
21
|
-
import base64, pickle, sys, traceback
|
|
23
|
+
import base64, os, pickle, sys, traceback
|
|
22
24
|
_PAYLOAD = "{payload}"
|
|
23
25
|
_BEGIN, _END = "{begin}", "{end}"
|
|
24
26
|
_VERSION = {version}
|
|
@@ -27,15 +29,22 @@ _NO_LETIFY = (
|
|
|
27
29
|
"to letify (for example through letify.session_cache), cannot be loaded. Add it to "
|
|
28
30
|
"the project with 'uv add letify' so uv.lock carries it into the runtime."
|
|
29
31
|
)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
)
|
|
32
|
+
_MODULES = {modules_code}
|
|
33
|
+
_WORKSPACE = {workspace_root!r}
|
|
34
|
+
if _MODULES:
|
|
35
|
+
_modules_dir = os.path.join(os.path.expanduser(_WORKSPACE), "modules")
|
|
36
|
+
os.makedirs(_modules_dir, exist_ok=True)
|
|
37
|
+
for _rel, _b64 in _MODULES.items():
|
|
38
|
+
_target = os.path.join(_modules_dir, _rel)
|
|
39
|
+
os.makedirs(os.path.dirname(_target), exist_ok=True)
|
|
40
|
+
with open(_target, "wb") as _f:
|
|
41
|
+
_f.write(base64.b64decode(_b64))
|
|
42
|
+
if _modules_dir not in sys.path:
|
|
43
|
+
sys.path.insert(0, _modules_dir)
|
|
44
|
+
_existing = os.environ.get("PYTHONPATH", "")
|
|
45
|
+
_parts = [p for p in _existing.split(os.pathsep) if p]
|
|
46
|
+
if _modules_dir not in _parts:
|
|
47
|
+
os.environ["PYTHONPATH"] = os.pathsep.join([_modules_dir, *_parts])
|
|
39
48
|
|
|
40
49
|
def _emit(obj):
|
|
41
50
|
try:
|
|
@@ -84,8 +93,6 @@ else:
|
|
|
84
93
|
except BaseException as exc:
|
|
85
94
|
if isinstance(exc, ModuleNotFoundError) and (exc.name or "").split(".")[0] == "letify":
|
|
86
95
|
_error = _NO_LETIFY
|
|
87
|
-
elif isinstance(exc, ModuleNotFoundError):
|
|
88
|
-
_error = _SHIP_IS_NOT_INSTALL.format(name=exc.name)
|
|
89
96
|
else:
|
|
90
97
|
_error = "{{}}: {{}}".format(type(exc).__name__, exc)
|
|
91
98
|
_emit({{
|
|
@@ -98,14 +105,36 @@ else:
|
|
|
98
105
|
"""
|
|
99
106
|
|
|
100
107
|
|
|
101
|
-
def build(
|
|
108
|
+
def build(
|
|
109
|
+
fn: Any,
|
|
110
|
+
args: tuple,
|
|
111
|
+
kwargs: dict,
|
|
112
|
+
*,
|
|
113
|
+
modules: Sequence[str] = (),
|
|
114
|
+
workspace_root: str = "~/.letify-runtime",
|
|
115
|
+
) -> str:
|
|
102
116
|
"""Return the script that runs one call and prints its outcome."""
|
|
103
117
|
payload = base64.b64encode(dumps_call(fn, args, kwargs)).decode()
|
|
118
|
+
modules_dict: dict[str, str] = {}
|
|
119
|
+
if modules:
|
|
120
|
+
from ..store.pathdata import collect_module
|
|
121
|
+
|
|
122
|
+
modules_root = f"{workspace_root.rstrip('/')}/modules"
|
|
123
|
+
for mod_name in modules:
|
|
124
|
+
placed = collect_module(mod_name, modules_root)
|
|
125
|
+
prefix = mod_name.replace(".", "/")
|
|
126
|
+
for rel, _digest, _size, local in placed.entries:
|
|
127
|
+
content = Path(local).read_bytes()
|
|
128
|
+
target_rel = f"{prefix}/{rel}" if placed.directory else prefix + Path(local).suffix
|
|
129
|
+
modules_dict[target_rel] = base64.b64encode(content).decode("ascii")
|
|
130
|
+
|
|
104
131
|
return _TEMPLATE.format(
|
|
105
132
|
payload=payload,
|
|
106
133
|
begin=BEGIN,
|
|
107
134
|
end=END,
|
|
108
135
|
version=PROTOCOL_VERSION,
|
|
136
|
+
modules_code=repr(modules_dict),
|
|
137
|
+
workspace_root=workspace_root,
|
|
109
138
|
)
|
|
110
139
|
|
|
111
140
|
|
|
@@ -195,16 +195,6 @@ _NO_LETIFY = (
|
|
|
195
195
|
"the project with 'uv add letify' so uv.lock carries it into the runtime."
|
|
196
196
|
)
|
|
197
197
|
|
|
198
|
-
_SHIP_IS_NOT_INSTALL = (
|
|
199
|
-
"{name!r} is not importable in this runtime while the call's body was running. "
|
|
200
|
-
"Env.ship() sends a module by value: an object from it that the function captures "
|
|
201
|
-
"as a global is carried inside the call's payload, but the module itself is never "
|
|
202
|
-
"installed in the runtime, so 'import {name}' inside the function body has nothing "
|
|
203
|
-
"to find. Reference the name as a global the function closes over instead of "
|
|
204
|
-
"importing it inside the body, or install the package in the runtime's environment "
|
|
205
|
-
"(name it in uv.lock) so it can be imported there by reference."
|
|
206
|
-
)
|
|
207
|
-
|
|
208
198
|
|
|
209
199
|
def _ship_main_to_children(cloudpickle):
|
|
210
200
|
"""Let a child process the body spawns rebuild what the caller's __main__ defined.
|
|
@@ -266,6 +256,15 @@ def _reply(stream, outcome):
|
|
|
266
256
|
|
|
267
257
|
def _op_call(request):
|
|
268
258
|
data = request.pop("data", None)
|
|
259
|
+
modules_dir = request.pop("modules_dir", None)
|
|
260
|
+
if modules_dir:
|
|
261
|
+
modules_dir = os.path.expanduser(modules_dir)
|
|
262
|
+
if modules_dir not in sys.path:
|
|
263
|
+
sys.path.insert(0, modules_dir)
|
|
264
|
+
existing = os.environ.get("PYTHONPATH", "")
|
|
265
|
+
parts = [p for p in existing.split(os.pathsep) if p]
|
|
266
|
+
if modules_dir not in parts:
|
|
267
|
+
os.environ["PYTHONPATH"] = os.pathsep.join([modules_dir, *parts])
|
|
269
268
|
if data is None:
|
|
270
269
|
return _call(request)
|
|
271
270
|
state = _data_register(data)
|
|
@@ -872,10 +871,18 @@ def _data_place_now(state, path, entry):
|
|
|
872
871
|
# turns it into a private copy. Spec "Materializing and the rewritten path".
|
|
873
872
|
linked = False
|
|
874
873
|
try:
|
|
875
|
-
os.
|
|
876
|
-
|
|
874
|
+
if os.path.exists(path) and os.stat(path).st_ino == os.stat(source).st_ino:
|
|
875
|
+
linked = True
|
|
877
876
|
except OSError:
|
|
878
877
|
pass
|
|
878
|
+
if not linked:
|
|
879
|
+
try:
|
|
880
|
+
if os.path.exists(path):
|
|
881
|
+
os.remove(path)
|
|
882
|
+
os.link(source, path)
|
|
883
|
+
linked = True
|
|
884
|
+
except OSError:
|
|
885
|
+
pass
|
|
879
886
|
if not linked:
|
|
880
887
|
# Copied to a name of its own and renamed into place, so a reader never stats a
|
|
881
888
|
# file that is half written. Spec "What the body sees before a file arrives".
|
|
@@ -1533,15 +1540,7 @@ def _call(request):
|
|
|
1533
1540
|
# refers to it when loaded, so the same explanation applies.
|
|
1534
1541
|
if (exc.name or "").split(".")[0] == "letify":
|
|
1535
1542
|
raise ModuleNotFoundError(_NO_LETIFY, name=exc.name) from exc
|
|
1536
|
-
|
|
1537
|
-
# sys.path: cloudpickle only puts a captured global in the payload, it
|
|
1538
|
-
# does not install anything. An import statement inside the body still
|
|
1539
|
-
# runs against this interpreter's own path and fails here, even though
|
|
1540
|
-
# the same name reached the call fine as a global. Say so, because the
|
|
1541
|
-
# bare ModuleNotFoundError reads as a missing dependency rather than as
|
|
1542
|
-
# this shape of call.
|
|
1543
|
-
explained = _SHIP_IS_NOT_INSTALL.format(name=exc.name)
|
|
1544
|
-
raise ModuleNotFoundError(explained, name=exc.name) from exc
|
|
1543
|
+
raise
|
|
1545
1544
|
return {"ok": True, "value": value}
|
|
1546
1545
|
|
|
1547
1546
|
|
|
@@ -99,8 +99,8 @@ class Provider(abc.ABC):
|
|
|
99
99
|
"""What this account has, read from the entry's device table.
|
|
100
100
|
|
|
101
101
|
This is the only thing that bounds how much runs at once. An entry that declares
|
|
102
|
-
nothing falls back to
|
|
103
|
-
provider that can be asked should not have to be told.
|
|
102
|
+
nothing falls back to the device counts of the shapes the provider discovers,
|
|
103
|
+
because a provider that can be asked should not have to be told.
|
|
104
104
|
"""
|
|
105
105
|
if self._inventory is None:
|
|
106
106
|
declared = read_table(self.config.options)
|
|
@@ -108,7 +108,7 @@ class Provider(abc.ABC):
|
|
|
108
108
|
# Keyed by what an instance calls itself, so a discovered shape and its
|
|
109
109
|
# inventory entry are the same name. A CPU shape reports "cpu".
|
|
110
110
|
declared = {
|
|
111
|
-
instance.accelerator: Devices(instance.accelerator)
|
|
111
|
+
instance.accelerator: Devices(instance.accelerator, count=instance.devices)
|
|
112
112
|
for instance in self.discover().values()
|
|
113
113
|
}
|
|
114
114
|
self._inventory = declared
|
|
@@ -278,10 +278,27 @@ class Colab(Shell):
|
|
|
278
278
|
return env
|
|
279
279
|
|
|
280
280
|
def _exec(self, session: str, source: str, timeout: float | None) -> str:
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
with account_lock(account_directory(self.alias) / ".config" / "colab-cli"):
|
|
281
|
+
try:
|
|
284
282
|
return self._cli("exec", "-s", session, stdin=source, timeout=timeout)
|
|
283
|
+
except subprocess.TimeoutExpired as exc:
|
|
284
|
+
stderr = exc.stderr or ""
|
|
285
|
+
if isinstance(stderr, bytes):
|
|
286
|
+
stderr = stderr.decode(errors="replace")
|
|
287
|
+
raise RuntimeFailure(
|
|
288
|
+
f"{session}: colab exec timed out after {timeout} s; "
|
|
289
|
+
"the notebook kernel may be busy or not answering",
|
|
290
|
+
command=" ".join([*self._colab(), "exec", "-s", session]),
|
|
291
|
+
stderr=stderr,
|
|
292
|
+
) from exc
|
|
293
|
+
except RuntimeFailure as exc:
|
|
294
|
+
if "Timeout waiting for reply" not in exc.stderr:
|
|
295
|
+
raise
|
|
296
|
+
raise RuntimeFailure(
|
|
297
|
+
f"{session}: colab exec did not receive a reply; "
|
|
298
|
+
"the notebook kernel may be busy or not answering",
|
|
299
|
+
command=exc.command,
|
|
300
|
+
stderr=exc.stderr,
|
|
301
|
+
) from exc
|
|
285
302
|
|
|
286
303
|
def sessions(self) -> list[str]:
|
|
287
304
|
"""Names of the sessions this account currently holds."""
|
|
@@ -309,8 +326,27 @@ class Colab(Shell):
|
|
|
309
326
|
from ..transport.rendezvous import ColabRendezvous
|
|
310
327
|
|
|
311
328
|
name = runtime.name
|
|
329
|
+
|
|
330
|
+
def prepare_tailcat() -> str | None:
|
|
331
|
+
from .. import install
|
|
332
|
+
|
|
333
|
+
if not (
|
|
334
|
+
install.find("tailcat", link_cache=False) or self.config.option("tailcat_binary")
|
|
335
|
+
):
|
|
336
|
+
return None
|
|
337
|
+
output = self._exec(name, install.remote_tailcat_source(), 180)
|
|
338
|
+
for line in output.splitlines():
|
|
339
|
+
if line.startswith("LETIFY-TAILCAT "):
|
|
340
|
+
return json.loads(line[len("LETIFY-TAILCAT ") :])
|
|
341
|
+
raise RuntimeFailure(
|
|
342
|
+
"tailcat installation gave no verified binary path",
|
|
343
|
+
stderr=output.strip(),
|
|
344
|
+
)
|
|
345
|
+
|
|
312
346
|
return ColabRendezvous(
|
|
313
|
-
lambda source, timeout: self._exec(name, source, timeout),
|
|
347
|
+
lambda source, timeout: self._exec(name, source, timeout),
|
|
348
|
+
self._public_key(),
|
|
349
|
+
prepare_tailcat=prepare_tailcat,
|
|
314
350
|
)
|
|
315
351
|
|
|
316
352
|
def fallback(self, runtime: Runtime | None = None) -> Callable[[], Link] | None:
|
|
@@ -330,20 +366,6 @@ class Colab(Shell):
|
|
|
330
366
|
def target(self, runtime: Runtime | None = None) -> Target:
|
|
331
367
|
target = super().target(runtime)
|
|
332
368
|
target.user = self.user or "root"
|
|
333
|
-
if runtime is not None and target.rendezvous.unavailable() is None:
|
|
334
|
-
from .. import install
|
|
335
|
-
|
|
336
|
-
if install.find("tailcat", link_cache=False) or self.config.option("tailcat_binary"):
|
|
337
|
-
output = self._exec(runtime.name, install.remote_tailcat_source(), 180)
|
|
338
|
-
for line in output.splitlines():
|
|
339
|
-
if line.startswith("LETIFY-TAILCAT "):
|
|
340
|
-
target.rendezvous.binary = json.loads(line[len("LETIFY-TAILCAT ") :])
|
|
341
|
-
break
|
|
342
|
-
else:
|
|
343
|
-
raise RuntimeFailure(
|
|
344
|
-
"tailcat installation gave no verified binary path",
|
|
345
|
-
stderr=output.strip(),
|
|
346
|
-
)
|
|
347
369
|
if runtime is not None:
|
|
348
370
|
# Each runtime is a new VM with a new host key.
|
|
349
371
|
target.host_key_alias = f"letify-{self.alias}-{runtime.name}"
|
|
@@ -507,5 +529,3 @@ __all__ = [
|
|
|
507
529
|
"Colab",
|
|
508
530
|
"ColabSessionReclaimed",
|
|
509
531
|
]
|
|
510
|
-
|
|
511
|
-
|
|
@@ -28,8 +28,8 @@ READY = "LETIFY-KEEP-ALIVE ready"
|
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
@contextlib.contextmanager
|
|
31
|
-
def account_lock(directory: Path) -> Iterator[
|
|
32
|
-
"""Serialize
|
|
31
|
+
def account_lock(directory: Path, *, blocking: bool = True) -> Iterator[bool]:
|
|
32
|
+
"""Serialize wakes sharing account CLI state, optionally without waiting."""
|
|
33
33
|
directory.mkdir(parents=True, exist_ok=True)
|
|
34
34
|
with (directory / "letify-wake.lock").open("a+b") as handle:
|
|
35
35
|
if os.name == "nt": # pragma: no cover - Windows file locking
|
|
@@ -38,13 +38,23 @@ def account_lock(directory: Path) -> Iterator[None]:
|
|
|
38
38
|
handle.write(b"\0")
|
|
39
39
|
handle.flush()
|
|
40
40
|
handle.seek(0)
|
|
41
|
-
|
|
41
|
+
try:
|
|
42
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK, 1)
|
|
43
|
+
except OSError:
|
|
44
|
+
if blocking:
|
|
45
|
+
raise
|
|
46
|
+
yield False
|
|
47
|
+
return
|
|
42
48
|
else:
|
|
43
49
|
import fcntl
|
|
44
50
|
|
|
45
|
-
|
|
51
|
+
try:
|
|
52
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB))
|
|
53
|
+
except BlockingIOError:
|
|
54
|
+
yield False
|
|
55
|
+
return
|
|
46
56
|
try:
|
|
47
|
-
yield
|
|
57
|
+
yield True
|
|
48
58
|
finally:
|
|
49
59
|
if os.name == "nt": # pragma: no cover - Windows file locking
|
|
50
60
|
handle.seek(0)
|
|
@@ -60,7 +70,15 @@ def wake(config: dict[str, Any]) -> float:
|
|
|
60
70
|
result = None
|
|
61
71
|
exc = None
|
|
62
72
|
try:
|
|
63
|
-
with account_lock(Path(config["directory"])):
|
|
73
|
+
with account_lock(Path(config["directory"]), blocking=False) as acquired:
|
|
74
|
+
if not acquired:
|
|
75
|
+
entry = record(config, "wake_skipped", reason="account_busy")
|
|
76
|
+
print(
|
|
77
|
+
f"{entry['timestamp']} letify: wake {config['session']} skipped: account busy",
|
|
78
|
+
file=sys.stderr,
|
|
79
|
+
flush=True,
|
|
80
|
+
)
|
|
81
|
+
return SUCCESS_INTERVAL
|
|
64
82
|
result = subprocess.run(
|
|
65
83
|
command, input="pass\n", capture_output=True, text=True, timeout=WAKE_TIMEOUT
|
|
66
84
|
)
|
|
@@ -65,12 +65,12 @@ if TYPE_CHECKING:
|
|
|
65
65
|
from ..runtime.channel import Channel
|
|
66
66
|
from ..runtime.session import Runtime
|
|
67
67
|
|
|
68
|
-
#:
|
|
69
|
-
#:
|
|
70
|
-
#:
|
|
68
|
+
#: GPU session shapes, with memory per card, device count and the internal API name.
|
|
69
|
+
#: P100 remains available through the API although the web UI no longer lists it.
|
|
70
|
+
#: CPU is the empty compute, so it carries no accelerator name.
|
|
71
71
|
GPUS = {
|
|
72
|
-
"P100": {"vram_gb": 16, "accelerator": "NVIDIA_TESLA_P100"},
|
|
73
|
-
"T4": {"vram_gb": 16, "accelerator": "NVIDIA_TESLA_T4"},
|
|
72
|
+
"P100": {"vram_gb": 16, "devices": 1, "accelerator": "NVIDIA_TESLA_P100"},
|
|
73
|
+
"T4": {"vram_gb": 16, "devices": 2, "accelerator": "NVIDIA_TESLA_T4"},
|
|
74
74
|
}
|
|
75
75
|
TPUS = ("TPU_V3_8",)
|
|
76
76
|
|
|
@@ -520,9 +520,9 @@ def cancel_run(cookie: str, run_id: int) -> None:
|
|
|
520
520
|
|
|
521
521
|
|
|
522
522
|
def read_api_token(alias: str) -> str | None:
|
|
523
|
-
"""Read the account's
|
|
523
|
+
"""Read the account's API token with file whitespace stripped, or None if unavailable."""
|
|
524
524
|
try:
|
|
525
|
-
token = (account_directory(alias) / "access_token").read_text(encoding="utf-8")
|
|
525
|
+
token = (account_directory(alias) / "access_token").read_text(encoding="utf-8").strip()
|
|
526
526
|
except (OSError, UnicodeError):
|
|
527
527
|
return None
|
|
528
528
|
return token or None
|
|
@@ -544,6 +544,15 @@ def require_api_token(alias: str) -> str:
|
|
|
544
544
|
return token
|
|
545
545
|
|
|
546
546
|
|
|
547
|
+
def read_notebook_owner(alias: str) -> str | None:
|
|
548
|
+
"""Read the public notebook owner saved by login, or None if unavailable."""
|
|
549
|
+
try:
|
|
550
|
+
owner = (account_directory(alias) / "username").read_text(encoding="utf-8").strip()
|
|
551
|
+
except (OSError, UnicodeError):
|
|
552
|
+
return None
|
|
553
|
+
return owner or None
|
|
554
|
+
|
|
555
|
+
|
|
547
556
|
def delete_notebook_via_cli(alias: str, slug: str | None) -> bool:
|
|
548
557
|
"""Delete the ephemeral notebook through the official CLI, the only way it is deleted.
|
|
549
558
|
|
|
@@ -558,11 +567,8 @@ def delete_notebook_via_cli(alias: str, slug: str | None) -> bool:
|
|
|
558
567
|
token = read_api_token(alias)
|
|
559
568
|
if token is None:
|
|
560
569
|
return False
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
except (OSError, UnicodeError):
|
|
564
|
-
return False
|
|
565
|
-
if not username:
|
|
570
|
+
username = read_notebook_owner(alias)
|
|
571
|
+
if username is None:
|
|
566
572
|
return False
|
|
567
573
|
uv = tools.find_uv()
|
|
568
574
|
if uv is None:
|
|
@@ -968,7 +974,10 @@ class Kaggle(Provider):
|
|
|
968
974
|
"""The fixed list of accelerators a Kaggle session offers. No call is made."""
|
|
969
975
|
table: dict[str, Instance] = {"CPU": Instance(self, gpu=None)}
|
|
970
976
|
table.update(
|
|
971
|
-
{
|
|
977
|
+
{
|
|
978
|
+
name: Instance(self, gpu=name, vram_gb=spec["vram_gb"], devices=spec["devices"])
|
|
979
|
+
for name, spec in GPUS.items()
|
|
980
|
+
}
|
|
972
981
|
)
|
|
973
982
|
table.update({name: Instance(self, tpu=name) for name in TPUS})
|
|
974
983
|
return table
|
|
@@ -1036,10 +1045,15 @@ class Kaggle(Provider):
|
|
|
1036
1045
|
The notebook is created fresh for this runtime alone, never reused from an earlier
|
|
1037
1046
|
one: spec "Kaggle session token chain".
|
|
1038
1047
|
"""
|
|
1048
|
+
if read_notebook_owner(self.alias) is None:
|
|
1049
|
+
raise ConfigError(
|
|
1050
|
+
f"{self.alias} has no Kaggle notebook owner. Log in again with: "
|
|
1051
|
+
f"letify login kaggle {self.alias} --username <owner>"
|
|
1052
|
+
)
|
|
1039
1053
|
cookie = self._require_live_cookie()
|
|
1040
1054
|
instance = getattr(runtime, "instance", None)
|
|
1041
1055
|
gpu = getattr(instance, "gpu", None)
|
|
1042
|
-
accelerator = GPUS[gpu]["accelerator"] if gpu in GPUS else None
|
|
1056
|
+
accelerator = GPUS[gpu]["accelerator"] if gpu in GPUS else getattr(instance, "tpu", None)
|
|
1043
1057
|
run, url, notebook, slug = live_session_url(self.alias, cookie, accelerator)
|
|
1044
1058
|
session = Session(self.alias, url)
|
|
1045
1059
|
session.wait_alive()
|
|
@@ -29,7 +29,7 @@ import subprocess
|
|
|
29
29
|
import sys
|
|
30
30
|
import threading
|
|
31
31
|
import time
|
|
32
|
-
from collections.abc import Callable, Iterator
|
|
32
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
33
33
|
from typing import IO, Any
|
|
34
34
|
|
|
35
35
|
from .. import protocol
|
|
@@ -112,6 +112,7 @@ class Channel(abc.ABC):
|
|
|
112
112
|
kwargs: dict,
|
|
113
113
|
*,
|
|
114
114
|
timeout: float | None = None,
|
|
115
|
+
modules: Sequence[str] = (),
|
|
115
116
|
) -> tuple[Any, str]:
|
|
116
117
|
"""Run one function call inside the runtime."""
|
|
117
118
|
head, buffers = protocol.dumps_call_parts(fn, args, kwargs)
|
|
@@ -908,10 +909,11 @@ class OneShotChannel(Channel):
|
|
|
908
909
|
kwargs: dict,
|
|
909
910
|
*,
|
|
910
911
|
timeout: float | None = None,
|
|
912
|
+
modules: Sequence[str] = (),
|
|
911
913
|
) -> tuple[Any, str]:
|
|
912
914
|
from ..protocol import driver
|
|
913
915
|
|
|
914
|
-
source = driver.build(fn, args, kwargs)
|
|
916
|
+
source = driver.build(fn, args, kwargs, modules=modules)
|
|
915
917
|
stdout = self._run(source, timeout)
|
|
916
918
|
logs, value = protocol.parse(stdout, runtime_key=self.name)
|
|
917
919
|
return value, logs
|