letify 2.4.0__tar.gz → 2.4.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-2.4.0 → letify-2.4.2}/PKG-INFO +1 -1
- {letify-2.4.0 → letify-2.4.2}/letify/__init__.py +1 -1
- {letify-2.4.0 → letify-2.4.2}/letify/cli.py +62 -0
- {letify-2.4.0 → letify-2.4.2}/letify/install.py +31 -0
- {letify-2.4.0 → letify-2.4.2}/letify/launcher.py +34 -0
- {letify-2.4.0 → letify-2.4.2}/letify/protocol/worker.py +107 -13
- {letify-2.4.0 → letify-2.4.2}/letify/providers/base.py +12 -0
- {letify-2.4.0 → letify-2.4.2}/letify/providers/kaggle.py +182 -21
- {letify-2.4.0 → letify-2.4.2}/letify-core/Cargo.lock +3 -3
- {letify-2.4.0 → letify-2.4.2}/letify-core/Cargo.toml +1 -1
- {letify-2.4.0 → letify-2.4.2}/letify-ext/package-lock.json +2 -2
- {letify-2.4.0 → letify-2.4.2}/letify-ext/package.json +1 -1
- {letify-2.4.0 → letify-2.4.2}/pyproject.toml +1 -1
- {letify-2.4.0 → letify-2.4.2}/tests/conftest.py +3 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_cli.py +75 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_data_streaming.py +128 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_install.py +42 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_kaggle.py +157 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_parallel_placement.py +52 -0
- {letify-2.4.0 → letify-2.4.2}/.github/workflows/ci.yml +0 -0
- {letify-2.4.0 → letify-2.4.2}/.github/workflows/docs.yml +0 -0
- {letify-2.4.0 → letify-2.4.2}/.github/workflows/publish.yml +0 -0
- {letify-2.4.0 → letify-2.4.2}/.gitignore +0 -0
- {letify-2.4.0 → letify-2.4.2}/.researchtree +0 -0
- {letify-2.4.0 → letify-2.4.2}/LICENSE +0 -0
- {letify-2.4.0 → letify-2.4.2}/README.md +0 -0
- {letify-2.4.0 → letify-2.4.2}/docs/guide/01-getting-started.md +0 -0
- {letify-2.4.0 → letify-2.4.2}/docs/guide/02-providers.md +0 -0
- {letify-2.4.0 → letify-2.4.2}/docs/guide/03-execution-modes.md +0 -0
- {letify-2.4.0 → letify-2.4.2}/docs/guide/04-environments-and-data.md +0 -0
- {letify-2.4.0 → letify-2.4.2}/docs/guide/05-concurrency.md +0 -0
- {letify-2.4.0 → letify-2.4.2}/docs/guide/06-cost.md +0 -0
- {letify-2.4.0 → letify-2.4.2}/docs/guide/07-troubleshooting.md +0 -0
- {letify-2.4.0 → letify-2.4.2}/docs/guide/08-releasing.md +0 -0
- {letify-2.4.0 → letify-2.4.2}/docs/guide/README.md +0 -0
- {letify-2.4.0 → letify-2.4.2}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
- {letify-2.4.0 → letify-2.4.2}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
- {letify-2.4.0 → letify-2.4.2}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
- {letify-2.4.0 → letify-2.4.2}/docs/guide/ko/04-/355/231/230/352/262/275/352/263/274-/353/215/260/354/235/264/355/204/260.md" +0 -0
- {letify-2.4.0 → letify-2.4.2}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
- {letify-2.4.0 → letify-2.4.2}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-2.4.0 → letify-2.4.2}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
- {letify-2.4.0 → letify-2.4.2}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-2.4.0 → letify-2.4.2}/docs/guide/ko/README.md +0 -0
- {letify-2.4.0 → letify-2.4.2}/docs/locales/README_ko.md +0 -0
- {letify-2.4.0 → letify-2.4.2}/docs/mkdocs.yml +0 -0
- {letify-2.4.0 → letify-2.4.2}/examples/README.md +0 -0
- {letify-2.4.0 → letify-2.4.2}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-2.4.0 → letify-2.4.2}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/examples/nvfp4-lora/README.md +0 -0
- {letify-2.4.0 → letify-2.4.2}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-2.4.0 → letify-2.4.2}/examples/nvfp4-lora/common.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/config/__init__.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/config/inventory.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/config/login.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/config/schema.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/config/secrets.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/config/writer.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/declare/__init__.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/declare/cache.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/declare/env.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/declare/function.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/declare/instance.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/errors.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/paths.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/protocol/__init__.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/protocol/codec.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/protocol/driver.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/protocol/handle.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/protocol/wire.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/providers/__init__.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/providers/colab.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/providers/colab_files.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/providers/colab_keepalive.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/providers/elice.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/providers/kaggle_adapter.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/providers/local.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/providers/modal.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/providers/modal_adapter.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/providers/naming.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/providers/shell.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/providers/tunnel.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/providers/usage.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/remoting/__init__.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/remoting/capability.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/remoting/device/__init__.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/remoting/device/client.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/remoting/device/cuda.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/remoting/device/executor.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/remoting/device/frames.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/remoting/device/guard.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/remoting/device/tensor.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/remoting/device/trace.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/remoting/device/value.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/remoting/loader.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/remoting/probe.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/render.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/runtime/__init__.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/runtime/bootstrap.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/runtime/channel.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/runtime/lease.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/runtime/pool.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/runtime/session.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/runtime/telemetry.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/store/__init__.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/store/backends/__init__.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/store/backends/filesystem.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/store/backends/google_auth.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/store/backends/layout.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/store/backends/objects.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/store/cas.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/store/pathdata.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/store/sendorder.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/store/volume.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/stubs.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/tools.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/transport/__init__.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/transport/agent.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/transport/announce.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/transport/link.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/transport/nat.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/transport/pipeline.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/transport/portmap.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/transport/probe.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/transport/quic.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/transport/rendezvous.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/transport/setup.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/transport/sshopts.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify/transport/strategies.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-core/.gitignore +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-core/build.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-core/letify-quic/Cargo.toml +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-core/letify-quic/src/main.rs +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-core/rust-toolchain.toml +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-ext/.vscodeignore +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-ext/LICENSE +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-ext/README.md +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-ext/media/icon.svg +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-ext/src/cli.ts +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-ext/src/extension.ts +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-ext/src/history.ts +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-ext/src/model.ts +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-ext/src/poll.ts +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-ext/src/render.ts +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-ext/test/cli.test.ts +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-ext/test/manifest.test.ts +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-ext/test/model.test.ts +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-ext/test/regressions.test.ts +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify-ext/tsconfig.json +0 -0
- {letify-2.4.0 → letify-2.4.2}/letify_providers.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/scripts/sync-main.sh +0 -0
- {letify-2.4.0 → letify-2.4.2}/scripts/version.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/fake_kaggle_adapter.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/fake_modal_adapter.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_cli_output.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_colab_keepalive.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_config.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_core.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_data.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_declare.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_device.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_editor_sessions.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_elice.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_frames.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_kaggle_cookie.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_kaggle_link.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_link_log.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_notebook.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_paths.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_portmap.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_protocol.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_providers.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_quic.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_remoting.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_render.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_runtime.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_ship.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_store.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_strategies.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_stubs.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_transport.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_tunnel_setup.py +0 -0
- {letify-2.4.0 → letify-2.4.2}/tests/test_version.py +0 -0
|
@@ -40,6 +40,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
40
40
|
sessions_parser.add_argument(
|
|
41
41
|
"--json", action="store_true", help="print the records unformatted"
|
|
42
42
|
)
|
|
43
|
+
stop = sub.add_parser("stop", help="stop what a dead process left behind")
|
|
44
|
+
stop.add_argument(
|
|
45
|
+
"--orphans",
|
|
46
|
+
action="store_true",
|
|
47
|
+
help="delete the sessions whose creating process is gone",
|
|
48
|
+
)
|
|
49
|
+
stop.add_argument("--alias", help="only this account")
|
|
50
|
+
stop.add_argument("-y", "--yes", action="store_true", help="do not ask")
|
|
51
|
+
stop.add_argument("--json", action="store_true", help="print the records unformatted")
|
|
52
|
+
|
|
43
53
|
sub.add_parser("stubs", help="write the provider types an editor completes")
|
|
44
54
|
|
|
45
55
|
usage = sub.add_parser("usage", help="show what each account has left")
|
|
@@ -523,6 +533,25 @@ def _say(kind: str, message: str) -> None:
|
|
|
523
533
|
print(f"{render.mark(kind, _out())} {message}")
|
|
524
534
|
|
|
525
535
|
|
|
536
|
+
def _detail(row: dict) -> str:
|
|
537
|
+
"""The reason a stop failed, when there is one to add."""
|
|
538
|
+
return f": {row['error']}" if row.get("error") else ""
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _confirmed(question: str) -> bool:
|
|
542
|
+
"""Ask before deleting. Anything but yes is no, and no terminal is no."""
|
|
543
|
+
import sys as _sys
|
|
544
|
+
|
|
545
|
+
if not _sys.stdin.isatty():
|
|
546
|
+
print(f"{question} not a terminal, so nothing is assumed. Pass -y to delete")
|
|
547
|
+
return False
|
|
548
|
+
try:
|
|
549
|
+
answer = input(f"{question} [y/N] ").strip().lower()
|
|
550
|
+
except EOFError:
|
|
551
|
+
return False
|
|
552
|
+
return answer in ("y", "yes")
|
|
553
|
+
|
|
554
|
+
|
|
526
555
|
def _json(value: object) -> int:
|
|
527
556
|
print(json.dumps(value, indent=2))
|
|
528
557
|
return 0
|
|
@@ -679,6 +708,39 @@ def _dispatch(args: argparse.Namespace) -> int:
|
|
|
679
708
|
print(f"{row['alias']} {detail}")
|
|
680
709
|
return 0
|
|
681
710
|
|
|
711
|
+
if args.command == "stop":
|
|
712
|
+
if not args.orphans:
|
|
713
|
+
# A bare `stop` reads as ending everything, so it ends nothing.
|
|
714
|
+
print("nothing was stopped. Pass --orphans to delete what a dead process left")
|
|
715
|
+
return 0
|
|
716
|
+
rows = let.orphans(args.alias)
|
|
717
|
+
found = [(row["alias"], ref) for row in rows for ref in row.get("orphans", ())]
|
|
718
|
+
unavailable = [row for row in rows if row.get("unavailable")]
|
|
719
|
+
if args.json and not found:
|
|
720
|
+
return _json(rows)
|
|
721
|
+
for row in unavailable:
|
|
722
|
+
print(f"{row['alias']} {row['unavailable']}")
|
|
723
|
+
if not found:
|
|
724
|
+
print("no orphaned sessions")
|
|
725
|
+
return 0
|
|
726
|
+
print(f"{len(found)} orphaned session(s) to delete:")
|
|
727
|
+
for alias, ref in found:
|
|
728
|
+
print(f" {alias} {ref}")
|
|
729
|
+
if not args.yes and not _confirmed("Delete them?"):
|
|
730
|
+
print("nothing was stopped")
|
|
731
|
+
return 0
|
|
732
|
+
done = let.stop_orphans(args.alias)
|
|
733
|
+
if args.json:
|
|
734
|
+
return _json(done)
|
|
735
|
+
failed = 0
|
|
736
|
+
for row in done:
|
|
737
|
+
if row.get("unavailable"):
|
|
738
|
+
continue
|
|
739
|
+
mark = "stopped" if row.get("stopped") else f"failed{_detail(row)}"
|
|
740
|
+
failed += 0 if row.get("stopped") else 1
|
|
741
|
+
print(f"{row['alias']} {row['ref']} {mark}")
|
|
742
|
+
return 1 if failed else 0
|
|
743
|
+
|
|
682
744
|
if args.command == "status":
|
|
683
745
|
status = let.status()
|
|
684
746
|
if args.json:
|
|
@@ -37,6 +37,10 @@ from .transport.setup import TAILCAT_VERSION
|
|
|
37
37
|
|
|
38
38
|
#: Seconds to wait for a release server.
|
|
39
39
|
DOWNLOAD_TIMEOUT = 60.0
|
|
40
|
+
#: Seconds to wait for the connection alone. A host a network blocks has to fail in
|
|
41
|
+
#: seconds, not after the read timeout, and twice that when a release redirects.
|
|
42
|
+
#: Spec "Automatic install".
|
|
43
|
+
CONNECT_TIMEOUT = 10.0
|
|
40
44
|
|
|
41
45
|
#: Bytes read from a release server at a time, so progress can be reported.
|
|
42
46
|
DOWNLOAD_CHUNK = 64 << 10
|
|
@@ -352,8 +356,35 @@ def _isatty(stream) -> bool:
|
|
|
352
356
|
return False
|
|
353
357
|
|
|
354
358
|
|
|
359
|
+
def _reachable(url: str) -> None:
|
|
360
|
+
"""Refuse a download whose host does not answer, before the request is made.
|
|
361
|
+
|
|
362
|
+
Spec "Automatic install": the error names the host so a blocked network is told what
|
|
363
|
+
to open or to configure around, rather than being told the tool is broken.
|
|
364
|
+
"""
|
|
365
|
+
import socket
|
|
366
|
+
import urllib.parse
|
|
367
|
+
|
|
368
|
+
parsed = urllib.parse.urlsplit(url)
|
|
369
|
+
host = parsed.hostname
|
|
370
|
+
if not host:
|
|
371
|
+
return
|
|
372
|
+
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
373
|
+
try:
|
|
374
|
+
socket.create_connection((host, port), timeout=CONNECT_TIMEOUT).close()
|
|
375
|
+
except OSError as exc:
|
|
376
|
+
raise InstallError(
|
|
377
|
+
f"could not reach {host}:{port} within {CONNECT_TIMEOUT:.0f} s ({exc}), so "
|
|
378
|
+
f"{url} was not downloaded. The host, not letify, is what is unreachable: a "
|
|
379
|
+
f"network that allows github.com can still block the host its releases "
|
|
380
|
+
f"redirect to. Fetch the archive another way and name the binary with the "
|
|
381
|
+
f"account's tailcat_binary or eci_binary"
|
|
382
|
+
) from None
|
|
383
|
+
|
|
384
|
+
|
|
355
385
|
def _download(url: str, *, label: str | None = None, stream=None) -> bytes:
|
|
356
386
|
"""Fetch ``url``; with ``label``, report progress on ``stream`` (standard error)."""
|
|
387
|
+
_reachable(url)
|
|
357
388
|
try:
|
|
358
389
|
with urllib.request.urlopen(url, timeout=DOWNLOAD_TIMEOUT) as response:
|
|
359
390
|
if label is None:
|
|
@@ -499,6 +499,40 @@ class Launcher:
|
|
|
499
499
|
rows.append({"alias": alias, "sessions": [], "unavailable": str(exc)})
|
|
500
500
|
return rows
|
|
501
501
|
|
|
502
|
+
def orphans(self, alias: str | None = None) -> list[dict[str, Any]]:
|
|
503
|
+
"""What a dead process left behind, per account.
|
|
504
|
+
|
|
505
|
+
Spec "Kaggle", stopping what a dead process left behind.
|
|
506
|
+
"""
|
|
507
|
+
rows: list[dict[str, Any]] = []
|
|
508
|
+
for name in self.config.order:
|
|
509
|
+
if alias is not None and name != alias:
|
|
510
|
+
continue
|
|
511
|
+
try:
|
|
512
|
+
provider = self.provider(name)
|
|
513
|
+
rows.append({"alias": name, "kind": provider.kind, "orphans": provider.orphans()})
|
|
514
|
+
except LetifyError as exc:
|
|
515
|
+
rows.append({"alias": name, "orphans": [], "unavailable": str(exc)})
|
|
516
|
+
return rows
|
|
517
|
+
|
|
518
|
+
def stop_orphans(self, alias: str | None = None) -> list[dict[str, Any]]:
|
|
519
|
+
"""Delete what ``orphans`` reported, one at a time, reporting each outcome."""
|
|
520
|
+
done: list[dict[str, Any]] = []
|
|
521
|
+
for row in self.orphans(alias):
|
|
522
|
+
if row.get("unavailable"):
|
|
523
|
+
done.append(row)
|
|
524
|
+
continue
|
|
525
|
+
provider = self.provider(row["alias"])
|
|
526
|
+
for ref in row["orphans"]:
|
|
527
|
+
try:
|
|
528
|
+
gone = provider.stop_orphan(ref)
|
|
529
|
+
done.append({"alias": row["alias"], "ref": ref, "stopped": gone})
|
|
530
|
+
except LetifyError as exc:
|
|
531
|
+
done.append(
|
|
532
|
+
{"alias": row["alias"], "ref": ref, "stopped": False, "error": str(exc)}
|
|
533
|
+
)
|
|
534
|
+
return done
|
|
535
|
+
|
|
502
536
|
def status(self) -> dict[str, Any]:
|
|
503
537
|
"""What is running right now, and what it is costing.
|
|
504
538
|
|
|
@@ -953,9 +953,12 @@ def _data_register(data):
|
|
|
953
953
|
# A call still waiting for bytes needs the patch to answer from the manifest,
|
|
954
954
|
# and a call that placed a link needs it for copy on write. Spec "Copy on write".
|
|
955
955
|
_data_install_patch()
|
|
956
|
-
if state["pending"]:
|
|
957
|
-
#
|
|
958
|
-
#
|
|
956
|
+
if state["pending"] or state["linked"]:
|
|
957
|
+
# A call still waiting for bytes needs the manifest on disk for the listings,
|
|
958
|
+
# and a call that placed a link needs it so a process the body starts can
|
|
959
|
+
# convert that link before writing through it: the patch a fresh process
|
|
960
|
+
# installs reads the layout from the manifest and installs nothing without it.
|
|
961
|
+
# Spec "Copy on write". Inside the lock: the data thread moves a path out of pending and
|
|
959
962
|
# into placed as each blob completes, and the manifest is written from both maps.
|
|
960
963
|
_data_write_manifest(state)
|
|
961
964
|
_data_observe(state)
|
|
@@ -1416,7 +1419,7 @@ def _data_write_manifest(state):
|
|
|
1416
1419
|
"""Write the call's manifest beside its files, for a process started fresh.
|
|
1417
1420
|
|
|
1418
1421
|
Spec "What the body sees before a file arrives": a spawned interpreter installs the
|
|
1419
|
-
same patch from ``
|
|
1422
|
+
same patch from ``sitecustomize.py`` and reads the layout from here, because it has
|
|
1420
1423
|
no channel of its own to ask on.
|
|
1421
1424
|
"""
|
|
1422
1425
|
import json
|
|
@@ -1435,10 +1438,11 @@ def _data_write_manifest(state):
|
|
|
1435
1438
|
directory = os.path.join(os.path.dirname(state["blobs"]), "pending")
|
|
1436
1439
|
try:
|
|
1437
1440
|
os.makedirs(directory, exist_ok=True)
|
|
1438
|
-
|
|
1441
|
+
# sitecustomize, not a .pth: site.py runs a .pth only in a directory it adds
|
|
1442
|
+
# through addsitedir, and a PYTHONPATH entry is not one, so a .pth there never
|
|
1443
|
+
# runs. Spec "What the body sees before a file arrives".
|
|
1444
|
+
with open(os.path.join(directory, "sitecustomize.py"), "w") as handle:
|
|
1439
1445
|
handle.write(_PENDING_MODULE)
|
|
1440
|
-
with open(os.path.join(directory, "letify_pending.pth"), "w") as handle:
|
|
1441
|
-
handle.write("import letify_pending\n")
|
|
1442
1446
|
except OSError:
|
|
1443
1447
|
return
|
|
1444
1448
|
existing = os.environ.get("PYTHONPATH", "")
|
|
@@ -1457,6 +1461,8 @@ import builtins
|
|
|
1457
1461
|
import io
|
|
1458
1462
|
import json
|
|
1459
1463
|
import os
|
|
1464
|
+
import shutil
|
|
1465
|
+
import sys
|
|
1460
1466
|
import time
|
|
1461
1467
|
|
|
1462
1468
|
|
|
@@ -1474,13 +1480,25 @@ def _install():
|
|
|
1474
1480
|
real_open, real_io_open = builtins.open, io.open
|
|
1475
1481
|
real_listdir, real_scandir = os.listdir, os.scandir
|
|
1476
1482
|
real_stat = os.stat
|
|
1483
|
+
real_os_open = os.open
|
|
1484
|
+
write_flags = os.O_WRONLY | os.O_RDWR | os.O_APPEND | os.O_TRUNC
|
|
1485
|
+
|
|
1486
|
+
# Whether the file exists, asked through the real stat. os.path.exists goes through
|
|
1487
|
+
# os.stat, and os.stat is what this patch replaces, so asking it here would call the
|
|
1488
|
+
# patch again and recurse without bottom.
|
|
1489
|
+
def there(text):
|
|
1490
|
+
try:
|
|
1491
|
+
real_stat(text)
|
|
1492
|
+
except OSError:
|
|
1493
|
+
return False
|
|
1494
|
+
return True
|
|
1477
1495
|
|
|
1478
1496
|
def missing(path):
|
|
1479
1497
|
try:
|
|
1480
1498
|
text = os.path.abspath(os.fspath(path))
|
|
1481
1499
|
except TypeError:
|
|
1482
1500
|
return None
|
|
1483
|
-
if not text.startswith(call_dir) or
|
|
1501
|
+
if not text.startswith(call_dir) or there(text):
|
|
1484
1502
|
return None
|
|
1485
1503
|
return text if text in files else None
|
|
1486
1504
|
|
|
@@ -1489,19 +1507,64 @@ def _install():
|
|
|
1489
1507
|
if text is None:
|
|
1490
1508
|
return
|
|
1491
1509
|
deadline = time.monotonic() + wait
|
|
1492
|
-
while not
|
|
1510
|
+
while not there(text):
|
|
1493
1511
|
if time.monotonic() >= deadline:
|
|
1494
1512
|
raise RuntimeError("letify: %s did not arrive within %.0f s" % (text, wait))
|
|
1495
1513
|
time.sleep(0.02)
|
|
1496
1514
|
|
|
1515
|
+
# A placed file is a hard link to the cache blob, so a write through it writes into
|
|
1516
|
+
# the cache. The worker converts the link first and this has to as well; it cannot read
|
|
1517
|
+
# the worker's placed map, so a link count above one under the call directory is what
|
|
1518
|
+
# it goes on. Spec "What the body sees before a file arrives".
|
|
1519
|
+
def convert(path):
|
|
1520
|
+
try:
|
|
1521
|
+
text = os.path.abspath(os.fspath(path))
|
|
1522
|
+
except TypeError:
|
|
1523
|
+
return
|
|
1524
|
+
if not text.startswith(call_dir):
|
|
1525
|
+
return
|
|
1526
|
+
try:
|
|
1527
|
+
if real_stat(text).st_nlink < 2:
|
|
1528
|
+
return
|
|
1529
|
+
except OSError:
|
|
1530
|
+
return
|
|
1531
|
+
partial = '%s.letify-placing.%d' % (text, os.getpid())
|
|
1532
|
+
try:
|
|
1533
|
+
shutil.copyfile(text, partial)
|
|
1534
|
+
os.replace(partial, text)
|
|
1535
|
+
except OSError:
|
|
1536
|
+
try:
|
|
1537
|
+
os.remove(partial)
|
|
1538
|
+
except OSError:
|
|
1539
|
+
pass
|
|
1540
|
+
|
|
1541
|
+
def writes(mode):
|
|
1542
|
+
return any(letter in mode for letter in ('w', 'a', 'x', '+'))
|
|
1543
|
+
|
|
1544
|
+
def ready(path, writing):
|
|
1545
|
+
wait_for(path)
|
|
1546
|
+
if writing:
|
|
1547
|
+
convert(path)
|
|
1548
|
+
|
|
1497
1549
|
def opened(file, *args, **kwargs):
|
|
1498
|
-
|
|
1550
|
+
mode = kwargs['mode'] if 'mode' in kwargs else (args[0] if args else 'r')
|
|
1551
|
+
ready(file, writes(mode) if isinstance(mode, str) else False)
|
|
1499
1552
|
return real_open(file, *args, **kwargs)
|
|
1500
1553
|
|
|
1501
1554
|
def io_opened(file, *args, **kwargs):
|
|
1502
|
-
|
|
1555
|
+
mode = kwargs['mode'] if 'mode' in kwargs else (args[0] if args else 'r')
|
|
1556
|
+
ready(file, writes(mode) if isinstance(mode, str) else False)
|
|
1503
1557
|
return real_io_open(file, *args, **kwargs)
|
|
1504
1558
|
|
|
1559
|
+
def os_opened(path, *args, **kwargs):
|
|
1560
|
+
flags = kwargs['flags'] if 'flags' in kwargs else (args[0] if args else 0)
|
|
1561
|
+
try:
|
|
1562
|
+
writing = bool(flags & write_flags)
|
|
1563
|
+
except TypeError:
|
|
1564
|
+
writing = False
|
|
1565
|
+
ready(path, writing)
|
|
1566
|
+
return real_os_open(path, *args, **kwargs)
|
|
1567
|
+
|
|
1505
1568
|
def names(path):
|
|
1506
1569
|
try:
|
|
1507
1570
|
text = os.path.abspath(os.fspath(path))
|
|
@@ -1534,12 +1597,37 @@ def _install():
|
|
|
1534
1597
|
|
|
1535
1598
|
builtins.open = opened
|
|
1536
1599
|
io.open = io_opened
|
|
1600
|
+
os.open = os_opened
|
|
1537
1601
|
os.listdir = listed
|
|
1538
1602
|
os.stat = stated
|
|
1539
1603
|
os.scandir = real_scandir
|
|
1540
1604
|
|
|
1541
1605
|
|
|
1606
|
+
# letify's directory goes first on PYTHONPATH, so it shadows a sitecustomize the user
|
|
1607
|
+
# installed. That one still has to run.
|
|
1608
|
+
def _chain():
|
|
1609
|
+
import importlib.util
|
|
1610
|
+
|
|
1611
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
1612
|
+
for entry in sys.path:
|
|
1613
|
+
if not entry:
|
|
1614
|
+
continue
|
|
1615
|
+
try:
|
|
1616
|
+
if os.path.abspath(entry) == here:
|
|
1617
|
+
continue
|
|
1618
|
+
candidate = os.path.join(entry, 'sitecustomize.py')
|
|
1619
|
+
if not os.path.isfile(candidate):
|
|
1620
|
+
continue
|
|
1621
|
+
spec = importlib.util.spec_from_file_location('letify_other_sitecustomize', candidate)
|
|
1622
|
+
module = importlib.util.module_from_spec(spec)
|
|
1623
|
+
spec.loader.exec_module(module)
|
|
1624
|
+
except Exception:
|
|
1625
|
+
pass
|
|
1626
|
+
return
|
|
1627
|
+
|
|
1628
|
+
|
|
1542
1629
|
_install()
|
|
1630
|
+
_chain()
|
|
1543
1631
|
"""
|
|
1544
1632
|
|
|
1545
1633
|
|
|
@@ -2126,12 +2214,18 @@ def _run(stream, request, settle):
|
|
|
2126
2214
|
request = None
|
|
2127
2215
|
if settle:
|
|
2128
2216
|
_settle()
|
|
2129
|
-
if name
|
|
2130
|
-
# Spec "Worker output": the
|
|
2217
|
+
if name in _DRAINS:
|
|
2218
|
+
# Spec "Worker output": what the program wrote is framed before its result goes
|
|
2219
|
+
# out. Every op that runs a program drains; a stat or a lease renewal does not,
|
|
2220
|
+
# because they write nothing and are on the hot path.
|
|
2131
2221
|
_drain_output()
|
|
2132
2222
|
_reply(stream, outcome)
|
|
2133
2223
|
|
|
2134
2224
|
|
|
2225
|
+
#: Ops that run a program, so output of theirs may still be unframed when they answer.
|
|
2226
|
+
#: Spec "Worker output".
|
|
2227
|
+
_DRAINS = frozenset(("call", "exec"))
|
|
2228
|
+
|
|
2135
2229
|
_DATA_PREFIX = b"LETIFY-DATA "
|
|
2136
2230
|
|
|
2137
2231
|
|
|
@@ -374,6 +374,18 @@ class Provider(abc.ABC):
|
|
|
374
374
|
"""
|
|
375
375
|
return []
|
|
376
376
|
|
|
377
|
+
def orphans(self) -> list[str]:
|
|
378
|
+
"""What a dead process left behind on this account, for a provider that can tell.
|
|
379
|
+
|
|
380
|
+
Spec "Kaggle", stopping what a dead process left behind. Empty for a provider that
|
|
381
|
+
cannot, so `letify stop --orphans` has nothing to delete there.
|
|
382
|
+
"""
|
|
383
|
+
return []
|
|
384
|
+
|
|
385
|
+
def stop_orphan(self, ref: str) -> bool:
|
|
386
|
+
"""Delete one thing `orphans` reported, and report whether it is gone."""
|
|
387
|
+
return False
|
|
388
|
+
|
|
377
389
|
def expected_cards(self, instance: Any) -> tuple[str, int] | None:
|
|
378
390
|
"""The card name fragment and count a session for ``instance`` must have, or None.
|
|
379
391
|
|