letify 1.1.2__tar.gz → 1.2.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-1.1.2 → letify-1.2.0}/PKG-INFO +2 -2
- {letify-1.1.2 → letify-1.2.0}/README.md +1 -1
- {letify-1.1.2 → letify-1.2.0}/docs/guide/07-troubleshooting.md +1 -7
- {letify-1.1.2 → letify-1.2.0}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md +1 -7
- {letify-1.1.2 → letify-1.2.0}/docs/locales/README_ko.md +1 -1
- {letify-1.1.2 → letify-1.2.0}/letify/__init__.py +1 -1
- {letify-1.1.2 → letify-1.2.0}/letify/cli.py +157 -13
- {letify-1.1.2 → letify-1.2.0}/letify/config/login.py +57 -8
- {letify-1.1.2 → letify-1.2.0}/letify/declare/function.py +6 -2
- {letify-1.1.2 → letify-1.2.0}/letify/install.py +90 -0
- {letify-1.1.2 → letify-1.2.0}/letify/launcher.py +66 -1
- letify-1.2.0/letify/paths.py +168 -0
- {letify-1.1.2 → letify-1.2.0}/letify/protocol/codec.py +27 -0
- {letify-1.1.2 → letify-1.2.0}/letify/protocol/driver.py +16 -5
- {letify-1.1.2 → letify-1.2.0}/letify/protocol/worker.py +145 -1
- {letify-1.1.2 → letify-1.2.0}/letify/providers/base.py +1 -1
- {letify-1.1.2 → letify-1.2.0}/letify/providers/colab.py +133 -4
- {letify-1.1.2 → letify-1.2.0}/letify/providers/colab_files.py +5 -1
- letify-1.2.0/letify/providers/colab_keepalive.py +170 -0
- {letify-1.1.2 → letify-1.2.0}/letify/providers/kaggle.py +312 -98
- {letify-1.1.2 → letify-1.2.0}/letify/providers/local.py +5 -1
- {letify-1.1.2 → letify-1.2.0}/letify/providers/modal.py +5 -1
- {letify-1.1.2 → letify-1.2.0}/letify/providers/shell.py +21 -1
- {letify-1.1.2 → letify-1.2.0}/letify/runtime/bootstrap.py +82 -11
- {letify-1.1.2 → letify-1.2.0}/letify/runtime/lease.py +13 -2
- {letify-1.1.2 → letify-1.2.0}/letify/runtime/pool.py +29 -6
- {letify-1.1.2 → letify-1.2.0}/letify/runtime/session.py +77 -26
- {letify-1.1.2 → letify-1.2.0}/letify/store/backends/__init__.py +3 -2
- {letify-1.1.2 → letify-1.2.0}/letify/store/pathdata.py +113 -7
- {letify-1.1.2 → letify-1.2.0}/letify/store/volume.py +24 -8
- {letify-1.1.2 → letify-1.2.0}/letify/tools.py +132 -8
- {letify-1.1.2 → letify-1.2.0}/letify/transport/pipeline.py +134 -5
- {letify-1.1.2 → letify-1.2.0}/letify/transport/rendezvous.py +3 -0
- {letify-1.1.2 → letify-1.2.0}/letify/transport/sshopts.py +3 -2
- {letify-1.1.2 → letify-1.2.0}/letify/transport/strategies.py +5 -1
- {letify-1.1.2 → letify-1.2.0}/letify-core/Cargo.lock +3 -3
- {letify-1.1.2 → letify-1.2.0}/letify-core/Cargo.toml +1 -1
- {letify-1.1.2 → letify-1.2.0}/letify-ext/README.md +7 -5
- {letify-1.1.2 → letify-1.2.0}/letify-ext/package-lock.json +2 -2
- {letify-1.1.2 → letify-1.2.0}/letify-ext/package.json +5 -5
- {letify-1.1.2 → letify-1.2.0}/letify-ext/src/cli.ts +21 -1
- {letify-1.1.2 → letify-1.2.0}/letify-ext/src/extension.ts +26 -35
- {letify-1.1.2 → letify-1.2.0}/letify-ext/src/model.ts +48 -0
- letify-1.2.0/letify-ext/src/poll.ts +14 -0
- {letify-1.1.2 → letify-1.2.0}/letify-ext/src/render.ts +29 -19
- letify-1.2.0/letify-ext/test/cli.test.ts +37 -0
- letify-1.2.0/letify-ext/test/manifest.test.ts +16 -0
- letify-1.2.0/letify-ext/test/regressions.test.ts +89 -0
- {letify-1.1.2 → letify-1.2.0}/pyproject.toml +1 -1
- {letify-1.1.2 → letify-1.2.0}/tests/conftest.py +80 -1
- {letify-1.1.2 → letify-1.2.0}/tests/test_cli.py +37 -1
- letify-1.2.0/tests/test_colab_keepalive.py +101 -0
- {letify-1.1.2 → letify-1.2.0}/tests/test_data.py +15 -5
- {letify-1.1.2 → letify-1.2.0}/tests/test_data_streaming.py +160 -2
- letify-1.2.0/tests/test_editor_sessions.py +64 -0
- {letify-1.1.2 → letify-1.2.0}/tests/test_install.py +45 -0
- {letify-1.1.2 → letify-1.2.0}/tests/test_kaggle.py +358 -71
- {letify-1.1.2 → letify-1.2.0}/tests/test_link_log.py +7 -2
- letify-1.2.0/tests/test_notebook.py +55 -0
- letify-1.2.0/tests/test_paths.py +304 -0
- {letify-1.1.2 → letify-1.2.0}/tests/test_protocol.py +117 -0
- {letify-1.1.2 → letify-1.2.0}/tests/test_providers.py +116 -9
- {letify-1.1.2 → letify-1.2.0}/tests/test_runtime.py +259 -7
- {letify-1.1.2 → letify-1.2.0}/tests/test_store.py +30 -1
- {letify-1.1.2 → letify-1.2.0}/tests/test_transport.py +123 -6
- {letify-1.1.2 → letify-1.2.0}/.github/workflows/ci.yml +0 -0
- {letify-1.1.2 → letify-1.2.0}/.github/workflows/docs.yml +0 -0
- {letify-1.1.2 → letify-1.2.0}/.github/workflows/publish.yml +0 -0
- {letify-1.1.2 → letify-1.2.0}/.gitignore +0 -0
- {letify-1.1.2 → letify-1.2.0}/.researchtree +0 -0
- {letify-1.1.2 → letify-1.2.0}/LICENSE +0 -0
- {letify-1.1.2 → letify-1.2.0}/docs/guide/01-getting-started.md +0 -0
- {letify-1.1.2 → letify-1.2.0}/docs/guide/02-providers.md +0 -0
- {letify-1.1.2 → letify-1.2.0}/docs/guide/03-execution-modes.md +0 -0
- {letify-1.1.2 → letify-1.2.0}/docs/guide/04-environments-and-data.md +0 -0
- {letify-1.1.2 → letify-1.2.0}/docs/guide/05-concurrency.md +0 -0
- {letify-1.1.2 → letify-1.2.0}/docs/guide/06-cost.md +0 -0
- {letify-1.1.2 → letify-1.2.0}/docs/guide/08-releasing.md +0 -0
- {letify-1.1.2 → letify-1.2.0}/docs/guide/README.md +0 -0
- {letify-1.1.2 → letify-1.2.0}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
- {letify-1.1.2 → letify-1.2.0}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
- {letify-1.1.2 → letify-1.2.0}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
- {letify-1.1.2 → letify-1.2.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-1.1.2 → letify-1.2.0}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
- {letify-1.1.2 → letify-1.2.0}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-1.1.2 → letify-1.2.0}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-1.1.2 → letify-1.2.0}/docs/guide/ko/README.md +0 -0
- {letify-1.1.2 → letify-1.2.0}/docs/mkdocs.yml +0 -0
- {letify-1.1.2 → letify-1.2.0}/examples/README.md +0 -0
- {letify-1.1.2 → letify-1.2.0}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-1.1.2 → letify-1.2.0}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/examples/nvfp4-lora/README.md +0 -0
- {letify-1.1.2 → letify-1.2.0}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-1.1.2 → letify-1.2.0}/examples/nvfp4-lora/common.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/config/__init__.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/config/inventory.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/config/schema.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/config/secrets.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/config/writer.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/declare/__init__.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/declare/cache.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/declare/env.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/declare/instance.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/errors.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/protocol/__init__.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/protocol/handle.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/protocol/wire.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/providers/__init__.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/providers/elice.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/providers/kaggle_adapter.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/providers/modal_adapter.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/providers/naming.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/providers/tunnel.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/providers/usage.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/remoting/__init__.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/remoting/capability.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/remoting/device/__init__.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/remoting/device/client.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/remoting/device/cuda.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/remoting/device/executor.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/remoting/device/frames.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/remoting/device/guard.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/remoting/device/tensor.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/remoting/device/trace.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/remoting/device/value.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/remoting/loader.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/remoting/probe.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/render.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/runtime/__init__.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/runtime/channel.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/runtime/telemetry.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/store/__init__.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/store/backends/filesystem.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/store/backends/google_auth.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/store/backends/layout.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/store/backends/objects.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/store/cas.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/store/sendorder.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/stubs.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/transport/__init__.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/transport/agent.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/transport/announce.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/transport/link.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/transport/nat.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/transport/probe.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify/transport/setup.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-core/.gitignore +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-core/build.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-core/rust-toolchain.toml +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-ext/.vscodeignore +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-ext/LICENSE +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-ext/media/icon.svg +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-ext/src/history.ts +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-ext/test/model.test.ts +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify-ext/tsconfig.json +0 -0
- {letify-1.1.2 → letify-1.2.0}/letify_providers.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/scripts/sync-main.sh +0 -0
- {letify-1.1.2 → letify-1.2.0}/scripts/version.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/tests/fake_kaggle_adapter.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/tests/fake_modal_adapter.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/tests/test_cli_output.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/tests/test_config.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/tests/test_core.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/tests/test_declare.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/tests/test_device.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/tests/test_elice.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/tests/test_frames.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/tests/test_kaggle_cookie.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/tests/test_remoting.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/tests/test_render.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/tests/test_strategies.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/tests/test_stubs.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/tests/test_tunnel_setup.py +0 -0
- {letify-1.1.2 → letify-1.2.0}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: letify
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.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
|
|
@@ -311,7 +311,7 @@ Provider
|
|
|
311
311
|
| 🇰🇷 `Elice` | persistent | Korean GPU cloud, per-second billing |
|
|
312
312
|
| 🏅 `Kaggle` | ephemeral | free weekly GPU hours, `host=letify.remote` only |
|
|
313
313
|
|
|
314
|
-
**Kaggle.** Log in to kaggle.com in a browser and copy the `Cookie` request header of that tab (developer tools, Network, any kaggle.com request), then run `letify login kaggle kaggle_a`. The cookie is asked for without echo, and `--cookie` takes the value or a file holding it. letify checks its shape and its expiry before any network call, proves it with one request that returns your account, and keeps it in `~/.letify/accounts/kaggle_a/cookie` with mode 0600. The cookie expires 30 days after the browser login that made it, and nothing letify does extends that. `letify providers` and `letify usage kaggle_a` show `cookie expires in N days`, and a run on an expired cookie stops with an error telling you to log in again and repeat `letify login kaggle kaggle_a`. `letify usage kaggle_a` also prints the GPU hours left this week.
|
|
314
|
+
**Kaggle.** Log in to kaggle.com in a browser and copy the `Cookie` request header of that tab (developer tools, Network, any kaggle.com request), then run `letify login kaggle kaggle_a`. The cookie is asked for without echo, and `--cookie` takes the value or a file holding it. letify checks its shape and its expiry before any network call, proves it with one request that returns your account, and keeps it in `~/.letify/accounts/kaggle_a/cookie` with mode 0600. The cookie expires 30 days after the browser login that made it, and nothing letify does extends that. `letify providers` and `letify usage kaggle_a` show `cookie expires in N days`, and a run on an expired cookie stops with an error telling you to log in again and repeat `letify login kaggle kaggle_a`. `letify usage kaggle_a` also prints the GPU hours left this week. Login also asks for your Kaggle username (the notebook owner) and the API token exactly as kaggle.com shows it, including `KGAT_`. The token is stored in `access_token` beside the cookie with mode 0600, and every Kaggle CLI command receives it through `KAGGLE_API_TOKEN`.
|
|
315
315
|
|
|
316
316
|
No step in the Kaggle editor is needed. letify starts the session itself, on a private notebook it creates in your account, and stops it when your program ends. The session runs one worker inside one kernel cell, and frames travel as base64 lines: about 9 MiB/s up and 1.5 MiB/s down, measured from Seoul. letify builds your declared environment there with `uv sync`, ships files, checks the worker's interpreter, and runs calls until Kaggle ends the session, after 20 minutes idle or 12 hours. It never sends a keep-alive.
|
|
317
317
|
|
|
@@ -286,7 +286,7 @@ Provider
|
|
|
286
286
|
| 🇰🇷 `Elice` | persistent | Korean GPU cloud, per-second billing |
|
|
287
287
|
| 🏅 `Kaggle` | ephemeral | free weekly GPU hours, `host=letify.remote` only |
|
|
288
288
|
|
|
289
|
-
**Kaggle.** Log in to kaggle.com in a browser and copy the `Cookie` request header of that tab (developer tools, Network, any kaggle.com request), then run `letify login kaggle kaggle_a`. The cookie is asked for without echo, and `--cookie` takes the value or a file holding it. letify checks its shape and its expiry before any network call, proves it with one request that returns your account, and keeps it in `~/.letify/accounts/kaggle_a/cookie` with mode 0600. The cookie expires 30 days after the browser login that made it, and nothing letify does extends that. `letify providers` and `letify usage kaggle_a` show `cookie expires in N days`, and a run on an expired cookie stops with an error telling you to log in again and repeat `letify login kaggle kaggle_a`. `letify usage kaggle_a` also prints the GPU hours left this week.
|
|
289
|
+
**Kaggle.** Log in to kaggle.com in a browser and copy the `Cookie` request header of that tab (developer tools, Network, any kaggle.com request), then run `letify login kaggle kaggle_a`. The cookie is asked for without echo, and `--cookie` takes the value or a file holding it. letify checks its shape and its expiry before any network call, proves it with one request that returns your account, and keeps it in `~/.letify/accounts/kaggle_a/cookie` with mode 0600. The cookie expires 30 days after the browser login that made it, and nothing letify does extends that. `letify providers` and `letify usage kaggle_a` show `cookie expires in N days`, and a run on an expired cookie stops with an error telling you to log in again and repeat `letify login kaggle kaggle_a`. `letify usage kaggle_a` also prints the GPU hours left this week. Login also asks for your Kaggle username (the notebook owner) and the API token exactly as kaggle.com shows it, including `KGAT_`. The token is stored in `access_token` beside the cookie with mode 0600, and every Kaggle CLI command receives it through `KAGGLE_API_TOKEN`.
|
|
290
290
|
|
|
291
291
|
No step in the Kaggle editor is needed. letify starts the session itself, on a private notebook it creates in your account, and stops it when your program ends. The session runs one worker inside one kernel cell, and frames travel as base64 lines: about 9 MiB/s up and 1.5 MiB/s down, measured from Seoul. letify builds your declared environment there with `uv sync`, ships files, checks the worker's interpreter, and runs calls until Kaggle ends the session, after 20 minutes idle or 12 hours. It never sends a keep-alive.
|
|
292
292
|
|
|
@@ -264,13 +264,7 @@ kind = "tunnel"
|
|
|
264
264
|
mtu = 1280
|
|
265
265
|
```
|
|
266
266
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
```python
|
|
270
|
-
let.providers.home_box.diagnose()
|
|
271
|
-
```
|
|
272
|
-
|
|
273
|
-
If it is relayed and UDP is blocked on your network, switch to `transport = "frp"` on TLS port 443. See [docs/NETWORK.md](../NETWORK.md).
|
|
267
|
+
letify holds every connection strategy, Tailcat's hole punch included, to a floor: a probe below 5 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).
|
|
274
268
|
|
|
275
269
|
### Gathered calls are not running in parallel
|
|
276
270
|
|
{letify-1.1.2 → letify-1.2.0}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md
RENAMED
|
@@ -264,13 +264,7 @@ kind = "tunnel"
|
|
|
264
264
|
mtu = 1280
|
|
265
265
|
```
|
|
266
266
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
```python
|
|
270
|
-
let.providers.home_box.diagnose()
|
|
271
|
-
```
|
|
272
|
-
|
|
273
|
-
중계이고 망에서 UDP가 막혀 있으면 TLS 443을 쓰는 `transport = "frp"`로 바꾸세요. [docs/NETWORK.md](../../NETWORK.md)를 보세요.
|
|
267
|
+
letify는 Tailcat 홀펀칭을 포함한 모든 연결 전략을 바닥값에 묶어 둡니다: 측정값이 5 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)를 보세요.
|
|
274
268
|
|
|
275
269
|
### 모은 호출이 병렬로 돌지 않습니다
|
|
276
270
|
|
|
@@ -280,7 +280,7 @@ Provider
|
|
|
280
280
|
| 🇰🇷 `Elice` | persistent | 한국 GPU 클라우드, 초 단위 과금 |
|
|
281
281
|
| 🏅 `Kaggle` | ephemeral | 주간 무료 GPU 시간, `host=letify.remote` 전용 |
|
|
282
282
|
|
|
283
|
-
**Kaggle.** 브라우저에서 kaggle.com에 로그인한 뒤 그 탭의 `Cookie` 요청 헤더를 복사하고(개발자 도구, Network, 아무 kaggle.com 요청), `letify login kaggle kaggle_a`를 실행합니다. 쿠키는 화면에 표시되지 않게 입력받고, `--cookie`로 값이나 값을 담은 파일을 넘겨도 됩니다. letify는 네트워크 호출 전에 쿠키의 형태와 만료를 검사하고, 계정을 돌려주는 요청 하나로 확인한 뒤 `~/.letify/accounts/kaggle_a/cookie`에 0600 권한으로 보관합니다. 쿠키는 그것을 만든 브라우저 로그인으로부터 30일 뒤에 만료되고, letify가 하는 어떤 일도 이를 연장하지 않습니다. `letify providers`와 `letify usage kaggle_a`는 `cookie expires in N days`를 보여 주고, 만료된 쿠키로 실행하면 다시 로그인해서 `letify login kaggle kaggle_a`를 반복하라는 오류로 멈춥니다. `letify usage kaggle_a`는 이번 주에 남은 GPU 시간도 보여 줍니다.
|
|
283
|
+
**Kaggle.** 브라우저에서 kaggle.com에 로그인한 뒤 그 탭의 `Cookie` 요청 헤더를 복사하고(개발자 도구, Network, 아무 kaggle.com 요청), `letify login kaggle kaggle_a`를 실행합니다. 쿠키는 화면에 표시되지 않게 입력받고, `--cookie`로 값이나 값을 담은 파일을 넘겨도 됩니다. letify는 네트워크 호출 전에 쿠키의 형태와 만료를 검사하고, 계정을 돌려주는 요청 하나로 확인한 뒤 `~/.letify/accounts/kaggle_a/cookie`에 0600 권한으로 보관합니다. 쿠키는 그것을 만든 브라우저 로그인으로부터 30일 뒤에 만료되고, letify가 하는 어떤 일도 이를 연장하지 않습니다. `letify providers`와 `letify usage kaggle_a`는 `cookie expires in N days`를 보여 주고, 만료된 쿠키로 실행하면 다시 로그인해서 `letify login kaggle kaggle_a`를 반복하라는 오류로 멈춥니다. `letify usage kaggle_a`는 이번 주에 남은 GPU 시간도 보여 줍니다. 로그인은 노트북 owner인 Kaggle 사용자 이름과 kaggle.com에 표시된 `KGAT_` 포함 API 토큰도 입력받습니다. 토큰은 쿠키 옆 `access_token`에 0600 권한으로 저장하며, 모든 Kaggle CLI 명령에 `KAGGLE_API_TOKEN` 환경 변수로 전달합니다.
|
|
284
284
|
|
|
285
285
|
Kaggle 편집기에서 할 일은 없습니다. letify가 계정에 비공개 노트북을 만들어 세션을 직접 시작하고, 프로그램이 끝나면 세션을 끝냅니다. 세션은 커널 셀 하나 안에서 워커 하나를 돌리고, 프레임은 base64 줄로 오갑니다. 서울에서 측정해 올리기 약 9 MiB/s, 내리기 약 1.5 MiB/s입니다. letify는 그 세션에서 선언된 환경을 `uv sync`로 만들고, 파일을 주고받고, 워커의 인터프리터를 검사하고, Kaggle이 세션을 끝낼 때까지 호출을 실행합니다. 세션은 20분 동안 쓰지 않거나 12시간이 지나면 끝납니다. keep-alive는 보내지 않습니다.
|
|
286
286
|
|
|
@@ -9,7 +9,10 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import argparse
|
|
11
11
|
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
12
14
|
import sys
|
|
15
|
+
from pathlib import Path
|
|
13
16
|
|
|
14
17
|
from . import __version__, render
|
|
15
18
|
from .config import login
|
|
@@ -33,6 +36,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
33
36
|
devices_parser.add_argument("--json", action="store_true", help="print the records unformatted")
|
|
34
37
|
status_parser = sub.add_parser("status", help="show live runtimes and what they are costing")
|
|
35
38
|
status_parser.add_argument("--json", action="store_true", help="print the records unformatted")
|
|
39
|
+
sessions_parser = sub.add_parser("sessions", help="list provider-owned sessions")
|
|
40
|
+
sessions_parser.add_argument(
|
|
41
|
+
"--json", action="store_true", help="print the records unformatted"
|
|
42
|
+
)
|
|
36
43
|
sub.add_parser("stubs", help="write the provider types an editor completes")
|
|
37
44
|
|
|
38
45
|
usage = sub.add_parser("usage", help="show what each account has left")
|
|
@@ -57,7 +64,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
57
64
|
type=int,
|
|
58
65
|
help="port forward SSH dials at --address when it differs from --port, for tunnel",
|
|
59
66
|
)
|
|
60
|
-
log_in.add_argument(
|
|
67
|
+
log_in.add_argument(
|
|
68
|
+
"--key", help="SSH private key path; with --username, the Kaggle API token instead"
|
|
69
|
+
)
|
|
61
70
|
log_in.add_argument(
|
|
62
71
|
"--auth",
|
|
63
72
|
choices=login.AUTH_METHODS,
|
|
@@ -89,6 +98,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
89
98
|
"--billing-endpoint", dest="billing_endpoint", help="Elice billing API base URL"
|
|
90
99
|
)
|
|
91
100
|
log_in.add_argument("--account", help="account email, for Colab")
|
|
101
|
+
log_in.add_argument(
|
|
102
|
+
"--username",
|
|
103
|
+
help=(
|
|
104
|
+
"Kaggle username, with --key the API token from Settings > API on kaggle.com; "
|
|
105
|
+
"required, for everything the official CLI covers that the cookie does not"
|
|
106
|
+
),
|
|
107
|
+
)
|
|
92
108
|
log_in.add_argument(
|
|
93
109
|
"--workspace",
|
|
94
110
|
metavar="PATH",
|
|
@@ -108,7 +124,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
108
124
|
metavar="COOKIE",
|
|
109
125
|
help=(
|
|
110
126
|
"the browser cookie of a logged-in kaggle.com tab, or a file holding it, for "
|
|
111
|
-
"kaggle;
|
|
127
|
+
"kaggle; required alongside --username/--key, which the official CLI needs"
|
|
112
128
|
),
|
|
113
129
|
)
|
|
114
130
|
log_in.add_argument(
|
|
@@ -135,6 +151,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
135
151
|
action="store_true",
|
|
136
152
|
help="ask an already declared machine for its GPUs again and replace its devices table",
|
|
137
153
|
)
|
|
154
|
+
log_in.add_argument(
|
|
155
|
+
"--replace",
|
|
156
|
+
action="store_true",
|
|
157
|
+
help="run the login again for an alias that already exists, to renew its credentials",
|
|
158
|
+
)
|
|
138
159
|
|
|
139
160
|
log_out = sub.add_parser("logout", help="remove an account from this machine")
|
|
140
161
|
log_out.add_argument("alias", help="provider alias to forget")
|
|
@@ -143,8 +164,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
143
164
|
check.add_argument("alias", help="provider alias from the configuration")
|
|
144
165
|
|
|
145
166
|
cache = sub.add_parser("cache", help="show the data caches, or clear a provider's")
|
|
146
|
-
cache.add_argument(
|
|
147
|
-
|
|
167
|
+
cache.add_argument(
|
|
168
|
+
"action",
|
|
169
|
+
nargs="?",
|
|
170
|
+
choices=("clear", "clean"),
|
|
171
|
+
help="clear a runtime cache or local cache",
|
|
172
|
+
)
|
|
173
|
+
cache.add_argument("alias", nargs="?", help="provider alias or local cache target to clear")
|
|
148
174
|
cache.add_argument("--json", action="store_true", help="print the records unformatted")
|
|
149
175
|
|
|
150
176
|
probe = sub.add_parser("probe", help="measure whether host='local' is worth using")
|
|
@@ -311,26 +337,114 @@ def _data_cache_request(let: Launcher, alias: str, clear: bool) -> dict:
|
|
|
311
337
|
let.pool.release(runtime)
|
|
312
338
|
|
|
313
339
|
|
|
340
|
+
def _measure_tree(path: Path) -> tuple[int, int]:
|
|
341
|
+
"""Return (file_count, total_bytes) for all files under path."""
|
|
342
|
+
if not path.exists():
|
|
343
|
+
return 0, 0
|
|
344
|
+
if path.is_file():
|
|
345
|
+
return 1, path.stat().st_size
|
|
346
|
+
files = 0
|
|
347
|
+
total_bytes = 0
|
|
348
|
+
for root, _, filenames in os.walk(path):
|
|
349
|
+
for name in filenames:
|
|
350
|
+
p = Path(root) / name
|
|
351
|
+
try:
|
|
352
|
+
stat_res = p.stat(follow_symlinks=False)
|
|
353
|
+
files += 1
|
|
354
|
+
total_bytes += stat_res.st_size
|
|
355
|
+
except OSError:
|
|
356
|
+
pass
|
|
357
|
+
return files, total_bytes
|
|
358
|
+
|
|
359
|
+
|
|
314
360
|
def _cache(let: Launcher, args: argparse.Namespace) -> int:
|
|
315
361
|
"""Spec "The cache command"."""
|
|
362
|
+
from .paths import (
|
|
363
|
+
accounts_directory,
|
|
364
|
+
cache_directory,
|
|
365
|
+
local_runtime_directory,
|
|
366
|
+
local_tmp_directory,
|
|
367
|
+
ssh_control_directory,
|
|
368
|
+
tools_directory,
|
|
369
|
+
)
|
|
316
370
|
from .store import pathdata
|
|
317
371
|
|
|
318
372
|
mib = 1 << 20
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
373
|
+
local_trees: dict[str, tuple[Path, bool]] = {
|
|
374
|
+
"cache": (cache_directory(), True),
|
|
375
|
+
"tools": (tools_directory(), True),
|
|
376
|
+
"tmp": (local_tmp_directory(), True),
|
|
377
|
+
"ssh": (ssh_control_directory(), True),
|
|
378
|
+
"runtime": (local_runtime_directory(), True),
|
|
379
|
+
"accounts": (accounts_directory(), False),
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if args.action in ("clear", "clean"):
|
|
383
|
+
target = args.alias
|
|
384
|
+
if not target:
|
|
385
|
+
_fail("name the provider or local tree to clear: letify cache clear <target>")
|
|
386
|
+
return 1
|
|
387
|
+
if target in ("accounts", "config", "config.toml"):
|
|
388
|
+
_fail(f"refused: {target} holds credentials or configuration and cannot be cleaned")
|
|
389
|
+
return 1
|
|
390
|
+
|
|
391
|
+
local_targets = ("cache", "tools", "tmp", "ssh", "runtime", "all")
|
|
392
|
+
if target in local_targets:
|
|
393
|
+
to_clean = ["cache", "tools", "tmp", "runtime"] if target == "all" else [target]
|
|
394
|
+
total_removed = 0
|
|
395
|
+
total_removed_bytes = 0
|
|
396
|
+
for item in to_clean:
|
|
397
|
+
dir_path, cleanable = local_trees[item]
|
|
398
|
+
if cleanable and dir_path.exists():
|
|
399
|
+
cnt, sz = _measure_tree(dir_path)
|
|
400
|
+
total_removed += cnt
|
|
401
|
+
total_removed_bytes += sz
|
|
402
|
+
for entry in list(dir_path.iterdir()):
|
|
403
|
+
if entry.is_dir():
|
|
404
|
+
shutil.rmtree(entry, ignore_errors=True)
|
|
405
|
+
else:
|
|
406
|
+
entry.unlink(missing_ok=True)
|
|
407
|
+
if args.json:
|
|
408
|
+
return _json(
|
|
409
|
+
{
|
|
410
|
+
"target": target,
|
|
411
|
+
"removed": total_removed,
|
|
412
|
+
"removed_bytes": total_removed_bytes,
|
|
413
|
+
}
|
|
414
|
+
)
|
|
415
|
+
print(
|
|
416
|
+
f"{target}: removed {total_removed} files "
|
|
417
|
+
f"{total_removed_bytes / mib:.1f} MiB"
|
|
418
|
+
)
|
|
419
|
+
return 0
|
|
420
|
+
|
|
421
|
+
# Provider target
|
|
422
|
+
try:
|
|
423
|
+
result = _data_cache_request(let, target, True)
|
|
424
|
+
except LetifyError as exc:
|
|
425
|
+
_fail(str(exc))
|
|
322
426
|
return 1
|
|
323
|
-
result = _data_cache_request(let, args.alias, True)
|
|
324
427
|
if args.json:
|
|
325
|
-
return _json({"alias":
|
|
428
|
+
return _json({"alias": target, **result})
|
|
326
429
|
print(
|
|
327
|
-
f"{
|
|
430
|
+
f"{target}: removed {result['removed']} files "
|
|
328
431
|
f"{result['removed_bytes'] / mib:.1f} MiB"
|
|
329
432
|
)
|
|
330
433
|
return 0
|
|
434
|
+
|
|
331
435
|
digests = pathdata.DigestCache()
|
|
332
436
|
pruned = digests.prune()
|
|
333
437
|
digests.save()
|
|
438
|
+
|
|
439
|
+
local_stats: dict[str, dict] = {}
|
|
440
|
+
for name, (path, cleanable) in local_trees.items():
|
|
441
|
+
count, size_bytes = _measure_tree(path)
|
|
442
|
+
local_stats[name] = {
|
|
443
|
+
"files": count,
|
|
444
|
+
"bytes": size_bytes,
|
|
445
|
+
"cleanable": cleanable,
|
|
446
|
+
}
|
|
447
|
+
|
|
334
448
|
records: list[dict] = []
|
|
335
449
|
for alias in let.config.order:
|
|
336
450
|
try:
|
|
@@ -351,10 +465,23 @@ def _cache(let: Launcher, args: argparse.Namespace) -> int:
|
|
|
351
465
|
"budget": result["budget"],
|
|
352
466
|
}
|
|
353
467
|
)
|
|
354
|
-
record = {
|
|
468
|
+
record = {
|
|
469
|
+
"digests": {"entries": len(digests), "pruned": pruned},
|
|
470
|
+
"local": local_stats,
|
|
471
|
+
"providers": records,
|
|
472
|
+
}
|
|
355
473
|
if args.json:
|
|
356
474
|
return _json(record)
|
|
475
|
+
|
|
357
476
|
print(f"digest cache: {len(digests)} entries, {pruned} pruned")
|
|
477
|
+
|
|
478
|
+
local_rows = []
|
|
479
|
+
for name, (_path, cleanable) in local_trees.items():
|
|
480
|
+
st = local_stats[name]
|
|
481
|
+
clean_str = "yes" if cleanable else "no (credentials)"
|
|
482
|
+
local_rows.append([name, str(st["files"]), f"{st['bytes'] / mib:.1f} MiB", clean_str])
|
|
483
|
+
sys.stdout.write(render.table(["LOCAL", "FILES", "SIZE", "CLEANABLE"], local_rows, _out()))
|
|
484
|
+
|
|
358
485
|
rows = []
|
|
359
486
|
for row in records:
|
|
360
487
|
if "unavailable" in row:
|
|
@@ -447,6 +574,7 @@ def _dispatch(args: argparse.Namespace) -> int:
|
|
|
447
574
|
"organization": args.organization,
|
|
448
575
|
"billing_endpoint": args.billing_endpoint,
|
|
449
576
|
"account": args.account,
|
|
577
|
+
"username": args.username,
|
|
450
578
|
"workspace": args.workspace,
|
|
451
579
|
"profile": args.profile,
|
|
452
580
|
"connect": args.connect,
|
|
@@ -457,6 +585,7 @@ def _dispatch(args: argparse.Namespace) -> int:
|
|
|
457
585
|
token=args.token,
|
|
458
586
|
interactive=args.interactive,
|
|
459
587
|
install_key=args.install_key,
|
|
588
|
+
replace=args.replace,
|
|
460
589
|
)
|
|
461
590
|
try:
|
|
462
591
|
fresh, home, project = login.log_in(answers, project=args.config)
|
|
@@ -464,9 +593,14 @@ def _dispatch(args: argparse.Namespace) -> int:
|
|
|
464
593
|
_fail(str(exc))
|
|
465
594
|
return 1
|
|
466
595
|
if fresh:
|
|
467
|
-
|
|
596
|
+
what = "renewed" if args.replace else "declared"
|
|
597
|
+
_say("ok", f"{alias} {what} in {home}")
|
|
468
598
|
else:
|
|
469
|
-
_say(
|
|
599
|
+
_say(
|
|
600
|
+
"warn",
|
|
601
|
+
f"{alias} was already declared in {home}, so nothing was asked for. "
|
|
602
|
+
f"Pass --replace to renew its credentials",
|
|
603
|
+
)
|
|
470
604
|
_say("ok", f"{alias} referenced in {project}, which is safe to commit")
|
|
471
605
|
return 0
|
|
472
606
|
|
|
@@ -535,6 +669,16 @@ def _dispatch(args: argparse.Namespace) -> int:
|
|
|
535
669
|
sys.stdout.write(render.table(["PROVIDER", "ACCELERATORS"], rows, _out()))
|
|
536
670
|
return 0
|
|
537
671
|
|
|
672
|
+
if args.command == "sessions":
|
|
673
|
+
rows = let.sessions()
|
|
674
|
+
if args.json:
|
|
675
|
+
return _json(rows)
|
|
676
|
+
for row in rows:
|
|
677
|
+
names = ", ".join(row["sessions"])
|
|
678
|
+
detail = row.get("unavailable") or row.get("reason") or names or "no active sessions"
|
|
679
|
+
print(f"{row['alias']} {detail}")
|
|
680
|
+
return 0
|
|
681
|
+
|
|
538
682
|
if args.command == "status":
|
|
539
683
|
status = let.status()
|
|
540
684
|
if args.json:
|
|
@@ -63,6 +63,9 @@ class Answers:
|
|
|
63
63
|
token: str | None = None
|
|
64
64
|
interactive: bool = True
|
|
65
65
|
install_key: bool = True
|
|
66
|
+
#: Run the kind's whole flow again for an alias that already exists, so a credential
|
|
67
|
+
#: can be renewed. Spec "Logging in".
|
|
68
|
+
replace: bool = False
|
|
66
69
|
|
|
67
70
|
def get(self, name: str) -> Any:
|
|
68
71
|
return self.values.get(name)
|
|
@@ -738,6 +741,7 @@ def colab_account(answers: Answers) -> dict[str, Any]:
|
|
|
738
741
|
[*tools.command(tools.COLAB, uv), "sessions"],
|
|
739
742
|
env=tools.environment(answers.alias),
|
|
740
743
|
)
|
|
744
|
+
tools.sync_tool_credentials(answers.alias)
|
|
741
745
|
if result.returncode != 0:
|
|
742
746
|
raise LoginError(f"the Colab sign in exited {result.returncode}, so nothing was written")
|
|
743
747
|
# The rendezvous installs this key's public half on each runtime, which is what lets
|
|
@@ -748,7 +752,7 @@ def colab_account(answers: Answers) -> dict[str, Any]:
|
|
|
748
752
|
return options
|
|
749
753
|
|
|
750
754
|
|
|
751
|
-
#: The cookie file and
|
|
755
|
+
#: The browser cookie file and its prompt.
|
|
752
756
|
KAGGLE_COOKIE = "cookie"
|
|
753
757
|
KAGGLE_COOKIE_PROMPT = "Kaggle cookie (from a logged-in kaggle.com tab): "
|
|
754
758
|
|
|
@@ -772,16 +776,56 @@ def read_kaggle_cookie(answers: Answers, given: str | None) -> str:
|
|
|
772
776
|
return text
|
|
773
777
|
|
|
774
778
|
|
|
775
|
-
|
|
776
|
-
|
|
779
|
+
#: The notebook owner and verbatim API token prompts.
|
|
780
|
+
KAGGLE_OWNER_PROMPT = "Kaggle username (notebook owner): "
|
|
781
|
+
KAGGLE_TOKEN_PROMPT = "Kaggle API token exactly as shown on kaggle.com, including KGAT_: "
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def read_kaggle_token(
|
|
785
|
+
answers: Answers, given_username: str | None, given_key: str | None
|
|
786
|
+
) -> tuple[str, str]:
|
|
787
|
+
"""Return the notebook owner and verbatim token, refusing malformed token input.
|
|
777
788
|
|
|
778
|
-
The
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
789
|
+
The owner addresses notebook deletion; only the token authenticates the CLI. Validate
|
|
790
|
+
the prefix and a nonempty whitespace-free body without imposing an unproven length.
|
|
791
|
+
"""
|
|
792
|
+
username = (given_username or "").strip()
|
|
793
|
+
if not username and answers.interactive:
|
|
794
|
+
username = read_line(KAGGLE_OWNER_PROMPT).strip()
|
|
795
|
+
from ..providers.kaggle import read_api_token
|
|
796
|
+
|
|
797
|
+
key = given_key if given_key is not None else (read_api_token(answers.alias) or "")
|
|
798
|
+
if not key and answers.interactive:
|
|
799
|
+
key = read_password(KAGGLE_TOKEN_PROMPT)
|
|
800
|
+
if not username or not key:
|
|
801
|
+
raise LoginError(
|
|
802
|
+
f"{answers.alias} needs the Kaggle API token and notebook owner: pass "
|
|
803
|
+
f"--username <owner> and --key <KGAT_token> from kaggle.com, or drop --no-input."
|
|
804
|
+
)
|
|
805
|
+
if not key.startswith("KGAT_") or len(key) <= len("KGAT_") or any(
|
|
806
|
+
character.isspace() for character in key
|
|
807
|
+
):
|
|
808
|
+
raise LoginError(
|
|
809
|
+
f"{answers.alias}: paste the Kaggle API token exactly as shown on kaggle.com, "
|
|
810
|
+
f"including KGAT_ and its nonempty body, with no whitespace."
|
|
811
|
+
)
|
|
812
|
+
return username, key
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def kaggle_account(answers: Answers) -> dict[str, Any]:
|
|
816
|
+
"""Store the browser session cookie and the API token, and prove the cookie is live.
|
|
817
|
+
|
|
818
|
+
Two credentials, and both are required. The cookie starts and ends the interactive
|
|
819
|
+
session and mints its Jupyter proxy URL; it is checked for shape and expiry before any
|
|
820
|
+
network call, then proven with a read of the account. The API token runs the official
|
|
821
|
+
CLI for everything else, deleting the notebook a run created and reading the weekly
|
|
822
|
+
quota; it is checked for shape only, since proving it would mean calling the CLI
|
|
823
|
+
from inside login, which this function leaves to the first command that uses it.
|
|
824
|
+
Nothing is written until every check passes.
|
|
782
825
|
"""
|
|
783
826
|
from ..providers import kaggle as kg
|
|
784
827
|
|
|
828
|
+
username, key = read_kaggle_token(answers, answers.get("username"), answers.get("key"))
|
|
785
829
|
given = answers.get(KAGGLE_COOKIE) or answers.token
|
|
786
830
|
cookie = read_kaggle_cookie(answers, given if isinstance(given, str) else None)
|
|
787
831
|
try:
|
|
@@ -802,6 +846,8 @@ def kaggle_account(answers: Answers) -> dict[str, Any]:
|
|
|
802
846
|
options: dict[str, Any] = {"kind": answers.kind}
|
|
803
847
|
record_workspace(answers, options)
|
|
804
848
|
store_secret(answers.alias, KAGGLE_COOKIE, cookie)
|
|
849
|
+
store_secret(answers.alias, "access_token", key)
|
|
850
|
+
store_secret(answers.alias, "username", username)
|
|
805
851
|
return options
|
|
806
852
|
|
|
807
853
|
|
|
@@ -947,6 +993,8 @@ def log_in(answers: Answers, *, project: str | Path | None = None) -> tuple[bool
|
|
|
947
993
|
Returns whether the account was newly written, and the two files touched. An account
|
|
948
994
|
already in the home file is not asked for again, which is the common case in a second
|
|
949
995
|
repository: the account was set up once and this project just needs to name it.
|
|
996
|
+
``replace`` runs the flow again for such an account, which is how a credential is
|
|
997
|
+
renewed. Spec "Logging in".
|
|
950
998
|
"""
|
|
951
999
|
check_alias(answers.alias)
|
|
952
1000
|
if answers.kind not in FLOWS:
|
|
@@ -955,7 +1003,8 @@ def log_in(answers: Answers, *, project: str | Path | None = None) -> tuple[bool
|
|
|
955
1003
|
|
|
956
1004
|
home = home_path()
|
|
957
1005
|
existing = home.read_text(encoding="utf-8") if home.is_file() else ""
|
|
958
|
-
|
|
1006
|
+
declared = not writer.has_block(existing, answers.alias)
|
|
1007
|
+
fresh = declared or answers.replace
|
|
959
1008
|
devices = None
|
|
960
1009
|
if fresh:
|
|
961
1010
|
options = FLOWS[answers.kind](answers)
|
|
@@ -161,7 +161,9 @@ class Function(Generic[R]):
|
|
|
161
161
|
# provider may name the failure first, such as a spot preemption.
|
|
162
162
|
exc = runtime.provider.diagnose(runtime, failure)
|
|
163
163
|
last = exc
|
|
164
|
-
launcher.pool.discard(
|
|
164
|
+
launcher.pool.discard(
|
|
165
|
+
runtime, reason=f"infrastructure_failure:{type(exc).__name__}: {exc}"
|
|
166
|
+
)
|
|
165
167
|
if attempt == self.retries:
|
|
166
168
|
if isinstance(exc, SpotPreempted):
|
|
167
169
|
raise exc from failure
|
|
@@ -205,7 +207,9 @@ class Function(Generic[R]):
|
|
|
205
207
|
_report_deferred(client.stats.snapshot() - before)
|
|
206
208
|
except (RuntimeFailure, ProtocolError) as failure:
|
|
207
209
|
named = runtime.provider.diagnose(runtime, failure)
|
|
208
|
-
launcher.pool.discard(
|
|
210
|
+
launcher.pool.discard(
|
|
211
|
+
runtime, reason=f"infrastructure_failure:{type(named).__name__}: {named}"
|
|
212
|
+
)
|
|
209
213
|
if named is failure:
|
|
210
214
|
raise
|
|
211
215
|
raise named from failure
|
|
@@ -205,6 +205,8 @@ def find(tool: str, *, link_cache: bool = True, say: Say = _say) -> str | None:
|
|
|
205
205
|
if paths is not None and paths[0].is_file():
|
|
206
206
|
recorded = _marker_version(paths[1]) if paths[1].is_file() else None
|
|
207
207
|
if recorded is None or recorded == version_of(tool):
|
|
208
|
+
if not link_cache and recorded == version_of(tool) and cache_path(tool).is_file():
|
|
209
|
+
return str(cache_path(tool))
|
|
208
210
|
return str(paths[0])
|
|
209
211
|
cached = cache_path(tool)
|
|
210
212
|
if cached.is_file():
|
|
@@ -517,6 +519,12 @@ def install(tool: str, *, say: Say = _say) -> Path:
|
|
|
517
519
|
if final.exists():
|
|
518
520
|
shutil.rmtree(final)
|
|
519
521
|
os.replace(staging, final)
|
|
522
|
+
for entry in list(final.parent.iterdir()):
|
|
523
|
+
if entry.name != final.name and not entry.name.startswith("."):
|
|
524
|
+
if entry.is_dir():
|
|
525
|
+
shutil.rmtree(entry, ignore_errors=True)
|
|
526
|
+
else:
|
|
527
|
+
entry.unlink(missing_ok=True)
|
|
520
528
|
except BaseException:
|
|
521
529
|
shutil.rmtree(staging, ignore_errors=True)
|
|
522
530
|
raise
|
|
@@ -604,3 +612,85 @@ __all__ = [
|
|
|
604
612
|
"link",
|
|
605
613
|
"where",
|
|
606
614
|
]
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def remote_tailcat_source() -> str:
|
|
618
|
+
"""Prepare the pinned Linux binary on Colab using this installer's release metadata."""
|
|
619
|
+
import inspect
|
|
620
|
+
|
|
621
|
+
return (
|
|
622
|
+
inspect.getsource(_remote_tailcat)
|
|
623
|
+
+ f"\nbinary = _remote_tailcat({TAILCAT_VERSION!r}, "
|
|
624
|
+
+ f"{TAILCAT_RELEASE.format(version=TAILCAT_VERSION)!r}, {TAILCAT_SHA256!r})\n"
|
|
625
|
+
+ "import json\nprint('LETIFY-TAILCAT ' + json.dumps(binary))\n"
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _remote_tailcat(version, release, pins):
|
|
630
|
+
"""Install a verified Linux tailcat using only the runtime's standard library."""
|
|
631
|
+
import hashlib
|
|
632
|
+
import io
|
|
633
|
+
import json
|
|
634
|
+
import os
|
|
635
|
+
import platform
|
|
636
|
+
import tarfile
|
|
637
|
+
import tempfile
|
|
638
|
+
import urllib.request
|
|
639
|
+
from pathlib import Path
|
|
640
|
+
|
|
641
|
+
arch = {"x86_64": "amd64", "aarch64": "arm64", "arm64": "arm64", "armv7l": "armv7"}.get(
|
|
642
|
+
platform.machine().lower()
|
|
643
|
+
)
|
|
644
|
+
asset = f"tailcat_{version}_linux_{arch}.tar.gz"
|
|
645
|
+
if platform.system() != "Linux" or asset not in pins:
|
|
646
|
+
raise RuntimeError(f"tailcat: unsupported runtime platform {platform.machine()}")
|
|
647
|
+
destination = Path.home() / ".letify" / "tools" / "tailcat" / version
|
|
648
|
+
binary = destination / "tailcat"
|
|
649
|
+
marker = destination / "verified.json"
|
|
650
|
+
if binary.is_file() and marker.is_file():
|
|
651
|
+
verified = json.loads(marker.read_text())
|
|
652
|
+
actual = hashlib.sha256(binary.read_bytes()).hexdigest()
|
|
653
|
+
if verified.get("archive") != pins[asset] or verified.get("binary") != actual:
|
|
654
|
+
raise RuntimeError("tailcat cached binary SHA-256 mismatch; refused")
|
|
655
|
+
return str(binary)
|
|
656
|
+
with urllib.request.urlopen(f"{release}/{asset}", timeout=60) as response:
|
|
657
|
+
data = response.read()
|
|
658
|
+
actual = hashlib.sha256(data).hexdigest()
|
|
659
|
+
if actual != pins[asset]:
|
|
660
|
+
raise RuntimeError(
|
|
661
|
+
f"tailcat archive SHA-256 mismatch: expected {pins[asset]}, got {actual}"
|
|
662
|
+
)
|
|
663
|
+
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive:
|
|
664
|
+
members = archive.getmembers()
|
|
665
|
+
if any(
|
|
666
|
+
Path(m.name).is_absolute()
|
|
667
|
+
or ".." in Path(m.name).parts
|
|
668
|
+
or m.isdev()
|
|
669
|
+
or m.islnk()
|
|
670
|
+
or m.issym()
|
|
671
|
+
for m in members
|
|
672
|
+
):
|
|
673
|
+
raise RuntimeError("tailcat archive contains an unsafe member; refused")
|
|
674
|
+
candidates = [
|
|
675
|
+
m
|
|
676
|
+
for m in members
|
|
677
|
+
if m.isfile() and Path(m.name).name == "tailcat" and len(Path(m.name).parts) <= 2
|
|
678
|
+
]
|
|
679
|
+
if len(candidates) != 1:
|
|
680
|
+
raise RuntimeError("tailcat archive must hold one regular tailcat binary")
|
|
681
|
+
payload = archive.extractfile(candidates[0]).read()
|
|
682
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
683
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".tailcat-", dir=destination)
|
|
684
|
+
try:
|
|
685
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
686
|
+
handle.write(payload)
|
|
687
|
+
os.chmod(temporary, 0o555)
|
|
688
|
+
os.replace(temporary, binary)
|
|
689
|
+
finally:
|
|
690
|
+
if os.path.exists(temporary):
|
|
691
|
+
os.unlink(temporary)
|
|
692
|
+
marker.write_text(
|
|
693
|
+
json.dumps({"archive": actual, "binary": hashlib.sha256(payload).hexdigest()})
|
|
694
|
+
)
|
|
695
|
+
marker.chmod(0o444)
|
|
696
|
+
return str(binary)
|