letify 1.2.1__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.1 → letify-1.2.2}/PKG-INFO +1 -1
- {letify-1.2.1 → letify-1.2.2}/docs/guide/04-environments-and-data.md +1 -1
- {letify-1.2.1 → 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.1 → letify-1.2.2}/letify/__init__.py +1 -1
- {letify-1.2.1 → letify-1.2.2}/letify/config/login.py +39 -7
- {letify-1.2.1 → letify-1.2.2}/letify/paths.py +1 -1
- {letify-1.2.1 → letify-1.2.2}/letify/protocol/driver.py +42 -13
- {letify-1.2.1 → letify-1.2.2}/letify/protocol/worker.py +20 -21
- {letify-1.2.1 → letify-1.2.2}/letify/providers/base.py +3 -3
- {letify-1.2.1 → letify-1.2.2}/letify/providers/colab.py +41 -39
- {letify-1.2.1 → letify-1.2.2}/letify/providers/colab_keepalive.py +24 -6
- {letify-1.2.1 → letify-1.2.2}/letify/providers/kaggle.py +10 -7
- {letify-1.2.1 → letify-1.2.2}/letify/runtime/channel.py +4 -2
- {letify-1.2.1 → letify-1.2.2}/letify/runtime/session.py +20 -7
- {letify-1.2.1 → letify-1.2.2}/letify/store/pathdata.py +113 -4
- {letify-1.2.1 → letify-1.2.2}/letify/tools.py +0 -23
- {letify-1.2.1 → letify-1.2.2}/letify/transport/rendezvous.py +16 -3
- {letify-1.2.1 → letify-1.2.2}/letify-core/Cargo.lock +3 -3
- {letify-1.2.1 → letify-1.2.2}/letify-core/Cargo.toml +1 -1
- {letify-1.2.1 → letify-1.2.2}/letify-ext/package-lock.json +2 -2
- {letify-1.2.1 → letify-1.2.2}/letify-ext/package.json +1 -1
- {letify-1.2.1 → letify-1.2.2}/pyproject.toml +1 -1
- {letify-1.2.1 → letify-1.2.2}/tests/test_colab_keepalive.py +22 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_kaggle.py +96 -3
- {letify-1.2.1 → letify-1.2.2}/tests/test_paths.py +1 -1
- {letify-1.2.1 → letify-1.2.2}/tests/test_protocol.py +16 -26
- {letify-1.2.1 → letify-1.2.2}/tests/test_providers.py +101 -6
- letify-1.2.2/tests/test_ship.py +239 -0
- {letify-1.2.1 → letify-1.2.2}/.github/workflows/ci.yml +0 -0
- {letify-1.2.1 → letify-1.2.2}/.github/workflows/docs.yml +0 -0
- {letify-1.2.1 → letify-1.2.2}/.github/workflows/publish.yml +0 -0
- {letify-1.2.1 → letify-1.2.2}/.gitignore +0 -0
- {letify-1.2.1 → letify-1.2.2}/.researchtree +0 -0
- {letify-1.2.1 → letify-1.2.2}/LICENSE +0 -0
- {letify-1.2.1 → letify-1.2.2}/README.md +0 -0
- {letify-1.2.1 → letify-1.2.2}/docs/guide/01-getting-started.md +0 -0
- {letify-1.2.1 → letify-1.2.2}/docs/guide/02-providers.md +0 -0
- {letify-1.2.1 → letify-1.2.2}/docs/guide/03-execution-modes.md +0 -0
- {letify-1.2.1 → letify-1.2.2}/docs/guide/05-concurrency.md +0 -0
- {letify-1.2.1 → letify-1.2.2}/docs/guide/06-cost.md +0 -0
- {letify-1.2.1 → letify-1.2.2}/docs/guide/07-troubleshooting.md +0 -0
- {letify-1.2.1 → letify-1.2.2}/docs/guide/08-releasing.md +0 -0
- {letify-1.2.1 → letify-1.2.2}/docs/guide/README.md +0 -0
- {letify-1.2.1 → 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.1 → 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.1 → 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.1 → 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.1 → letify-1.2.2}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-1.2.1 → letify-1.2.2}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
- {letify-1.2.1 → letify-1.2.2}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-1.2.1 → letify-1.2.2}/docs/guide/ko/README.md +0 -0
- {letify-1.2.1 → letify-1.2.2}/docs/locales/README_ko.md +0 -0
- {letify-1.2.1 → letify-1.2.2}/docs/mkdocs.yml +0 -0
- {letify-1.2.1 → letify-1.2.2}/examples/README.md +0 -0
- {letify-1.2.1 → letify-1.2.2}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-1.2.1 → letify-1.2.2}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/examples/nvfp4-lora/README.md +0 -0
- {letify-1.2.1 → letify-1.2.2}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-1.2.1 → letify-1.2.2}/examples/nvfp4-lora/common.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/cli.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/config/__init__.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/config/inventory.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/config/schema.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/config/secrets.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/config/writer.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/declare/__init__.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/declare/cache.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/declare/env.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/declare/function.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/declare/instance.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/errors.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/install.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/launcher.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/protocol/__init__.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/protocol/codec.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/protocol/handle.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/protocol/wire.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/providers/__init__.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/providers/colab_files.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/providers/elice.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/providers/kaggle_adapter.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/providers/local.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/providers/modal.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/providers/modal_adapter.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/providers/naming.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/providers/shell.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/providers/tunnel.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/providers/usage.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/remoting/__init__.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/remoting/capability.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/remoting/device/__init__.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/remoting/device/client.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/remoting/device/cuda.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/remoting/device/executor.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/remoting/device/frames.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/remoting/device/guard.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/remoting/device/tensor.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/remoting/device/trace.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/remoting/device/value.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/remoting/loader.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/remoting/probe.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/render.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/runtime/__init__.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/runtime/bootstrap.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/runtime/lease.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/runtime/pool.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/runtime/telemetry.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/store/__init__.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/store/backends/__init__.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/store/backends/filesystem.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/store/backends/google_auth.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/store/backends/layout.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/store/backends/objects.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/store/cas.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/store/sendorder.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/store/volume.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/stubs.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/transport/__init__.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/transport/agent.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/transport/announce.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/transport/link.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/transport/nat.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/transport/pipeline.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/transport/probe.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/transport/setup.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/transport/sshopts.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify/transport/strategies.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-core/.gitignore +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-core/build.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-core/rust-toolchain.toml +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-ext/.vscodeignore +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-ext/LICENSE +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-ext/README.md +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-ext/media/icon.svg +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-ext/src/cli.ts +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-ext/src/extension.ts +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-ext/src/history.ts +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-ext/src/model.ts +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-ext/src/poll.ts +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-ext/src/render.ts +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-ext/test/cli.test.ts +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-ext/test/manifest.test.ts +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-ext/test/model.test.ts +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-ext/test/regressions.test.ts +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify-ext/tsconfig.json +0 -0
- {letify-1.2.1 → letify-1.2.2}/letify_providers.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/scripts/sync-main.sh +0 -0
- {letify-1.2.1 → letify-1.2.2}/scripts/version.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/conftest.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/fake_kaggle_adapter.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/fake_modal_adapter.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_cli.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_cli_output.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_config.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_core.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_data.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_data_streaming.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_declare.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_device.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_editor_sessions.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_elice.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_frames.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_install.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_kaggle_cookie.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_link_log.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_notebook.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_remoting.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_render.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_runtime.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_store.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_strategies.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_stubs.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_transport.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_tunnel_setup.py +0 -0
- {letify-1.2.1 → letify-1.2.2}/tests/test_version.py +0 -0
|
@@ -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.
|
|
@@ -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이고, 비싼 것은 환경과 데이터이며 그건 캐시됩니다.
|
|
@@ -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,30 +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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
command=exc.command,
|
|
303
|
-
stderr=exc.stderr,
|
|
304
|
-
) from exc
|
|
281
|
+
try:
|
|
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
|
|
305
302
|
|
|
306
303
|
def sessions(self) -> list[str]:
|
|
307
304
|
"""Names of the sessions this account currently holds."""
|
|
@@ -329,8 +326,27 @@ class Colab(Shell):
|
|
|
329
326
|
from ..transport.rendezvous import ColabRendezvous
|
|
330
327
|
|
|
331
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
|
+
|
|
332
346
|
return ColabRendezvous(
|
|
333
|
-
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,
|
|
334
350
|
)
|
|
335
351
|
|
|
336
352
|
def fallback(self, runtime: Runtime | None = None) -> Callable[[], Link] | None:
|
|
@@ -350,20 +366,6 @@ class Colab(Shell):
|
|
|
350
366
|
def target(self, runtime: Runtime | None = None) -> Target:
|
|
351
367
|
target = super().target(runtime)
|
|
352
368
|
target.user = self.user or "root"
|
|
353
|
-
if runtime is not None and target.rendezvous.unavailable() is None:
|
|
354
|
-
from .. import install
|
|
355
|
-
|
|
356
|
-
if install.find("tailcat", link_cache=False) or self.config.option("tailcat_binary"):
|
|
357
|
-
output = self._exec(runtime.name, install.remote_tailcat_source(), 180)
|
|
358
|
-
for line in output.splitlines():
|
|
359
|
-
if line.startswith("LETIFY-TAILCAT "):
|
|
360
|
-
target.rendezvous.binary = json.loads(line[len("LETIFY-TAILCAT ") :])
|
|
361
|
-
break
|
|
362
|
-
else:
|
|
363
|
-
raise RuntimeFailure(
|
|
364
|
-
"tailcat installation gave no verified binary path",
|
|
365
|
-
stderr=output.strip(),
|
|
366
|
-
)
|
|
367
369
|
if runtime is not None:
|
|
368
370
|
# Each runtime is a new VM with a new host key.
|
|
369
371
|
target.host_key_alias = f"letify-{self.alias}-{runtime.name}"
|
|
@@ -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
|
|
|
@@ -974,7 +974,10 @@ class Kaggle(Provider):
|
|
|
974
974
|
"""The fixed list of accelerators a Kaggle session offers. No call is made."""
|
|
975
975
|
table: dict[str, Instance] = {"CPU": Instance(self, gpu=None)}
|
|
976
976
|
table.update(
|
|
977
|
-
{
|
|
977
|
+
{
|
|
978
|
+
name: Instance(self, gpu=name, vram_gb=spec["vram_gb"], devices=spec["devices"])
|
|
979
|
+
for name, spec in GPUS.items()
|
|
980
|
+
}
|
|
978
981
|
)
|
|
979
982
|
table.update({name: Instance(self, tpu=name) for name in TPUS})
|
|
980
983
|
return table
|
|
@@ -1050,7 +1053,7 @@ class Kaggle(Provider):
|
|
|
1050
1053
|
cookie = self._require_live_cookie()
|
|
1051
1054
|
instance = getattr(runtime, "instance", None)
|
|
1052
1055
|
gpu = getattr(instance, "gpu", None)
|
|
1053
|
-
accelerator = GPUS[gpu]["accelerator"] if gpu in GPUS else None
|
|
1056
|
+
accelerator = GPUS[gpu]["accelerator"] if gpu in GPUS else getattr(instance, "tpu", None)
|
|
1054
1057
|
run, url, notebook, slug = live_session_url(self.alias, cookie, accelerator)
|
|
1055
1058
|
session = Session(self.alias, url)
|
|
1056
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
|
|
@@ -276,13 +276,11 @@ class Runtime:
|
|
|
276
276
|
"""
|
|
277
277
|
if self.channel is None:
|
|
278
278
|
raise RuntimeFailure(f"{self.name}: the channel is not open")
|
|
279
|
-
# Done here rather than at declaration time, because the registration lives in
|
|
280
|
-
# cloudpickle and a process can hold declarations with different environments.
|
|
281
|
-
if self.env.ship_modules:
|
|
282
|
-
protocol.codec.ship_by_value(self.env.ship_modules)
|
|
283
279
|
if not self.persistent_channel:
|
|
284
280
|
self.last_used = time.monotonic()
|
|
285
|
-
return self.channel.call(
|
|
281
|
+
return self.channel.call(
|
|
282
|
+
fn, args, kwargs, timeout=timeout, modules=self.env.ship_modules
|
|
283
|
+
)
|
|
286
284
|
args, kwargs = self._externalize(args, kwargs)
|
|
287
285
|
return self._call_with_data(fn, args, kwargs, timeout, data_order, data_first_wave)
|
|
288
286
|
|
|
@@ -308,17 +306,27 @@ class Runtime:
|
|
|
308
306
|
assert self.channel is not None
|
|
309
307
|
root = (self.workspace or self.provider.workspace_root).rstrip("/")
|
|
310
308
|
call_dir = f"{root}/data/calls/{secrets.token_hex(8)}"
|
|
309
|
+
modules_root = f"{root}/modules"
|
|
311
310
|
collector = pathdata.Collector(call_dir)
|
|
311
|
+
ship_digests: set[str] = set()
|
|
312
|
+
if self.env.ship_modules:
|
|
313
|
+
for name in self.env.ship_modules:
|
|
314
|
+
placed = collector.collect_module(name, modules_root)
|
|
315
|
+
for _rel, digest, _size, _loc in placed.entries:
|
|
316
|
+
ship_digests.add(digest)
|
|
312
317
|
head, buffers = protocol.dumps_call_parts(fn, args, kwargs, data=collector)
|
|
313
318
|
request: dict[str, Any] = {"op": "call", "payload": head, "buffers": buffers}
|
|
314
319
|
blobs = f"{root}/data/blobs"
|
|
315
320
|
stream: pathdata.Stream | None = None
|
|
321
|
+
if self.env.ship_modules:
|
|
322
|
+
request["modules_dir"] = modules_root
|
|
316
323
|
if collector.placed:
|
|
317
|
-
|
|
324
|
+
extra_dirs = [modules_root] if self.env.ship_modules else ()
|
|
325
|
+
request["data"] = collector.request(blobs, extra_dirs=extra_dirs)
|
|
318
326
|
if collector.inputs:
|
|
319
327
|
stream = self._stream_data(
|
|
320
328
|
collector, request["data"], blobs, fn, args, kwargs,
|
|
321
|
-
data_order, data_first_wave,
|
|
329
|
+
data_order, data_first_wave, ship_digests=ship_digests,
|
|
322
330
|
)
|
|
323
331
|
self.last_used = time.monotonic()
|
|
324
332
|
outcome = None
|
|
@@ -351,6 +359,7 @@ class Runtime:
|
|
|
351
359
|
kwargs: dict,
|
|
352
360
|
data_order: Any,
|
|
353
361
|
data_first_wave: int | None,
|
|
362
|
+
ship_digests: set[str] | None = None,
|
|
354
363
|
) -> Any:
|
|
355
364
|
"""Derive the send order, place the first wave, and start the background sender."""
|
|
356
365
|
from ..store import pathdata, sendorder
|
|
@@ -367,6 +376,10 @@ class Runtime:
|
|
|
367
376
|
files=int(option("data_first_wave_files", pathdata.FIRST_WAVE_FILES)),
|
|
368
377
|
count=data_first_wave,
|
|
369
378
|
)
|
|
379
|
+
if ship_digests:
|
|
380
|
+
needed = [d for d in plan.missing if d in ship_digests and d not in wave]
|
|
381
|
+
if needed:
|
|
382
|
+
wave = needed + wave
|
|
370
383
|
stream = pathdata.Stream(self, blobs, plan)
|
|
371
384
|
if not plan.missing:
|
|
372
385
|
# Nothing to stream: the runtime holds every file, so the call places them all
|