fcloud-sdk 0.2.1__tar.gz → 0.3.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.
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/CHANGELOG.md +55 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/PKG-INFO +70 -3
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/README.md +66 -1
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/SKILL.md +16 -1
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/pyproject.toml +7 -4
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/SKILL.md +16 -1
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/__init__.py +21 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/__main__.py +1 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/_direct_bridge.py +87 -19
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/_legacy_env.py +5 -0
- fcloud_sdk-0.3.0/src/fcloud/_modal_runner.py +101 -0
- fcloud_sdk-0.3.0/src/fcloud/billing_types.py +241 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/__init__.py +2 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/attach.py +43 -6
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/common.py +2 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/console.py +3 -1
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/context.py +49 -0
- fcloud_sdk-0.3.0/src/fcloud/cli/credits.py +215 -0
- fcloud_sdk-0.3.0/src/fcloud/cli/credits_buy.py +97 -0
- fcloud_sdk-0.3.0/src/fcloud/cli/credits_render.py +129 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/exec_cmd.py +4 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/files.py +23 -10
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/help.py +3 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/interactive.py +7 -1
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/job.py +5 -1
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/main.py +18 -1
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/migration.py +14 -1
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/processes.py +8 -1
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/run.py +16 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/sessions.py +5 -1
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/ssh.py +2 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/sweep.py +41 -4
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/sweep_watch.py +12 -2
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/usage.py +2 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/volume.py +2 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/wait.py +2 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli_args.py +9 -1
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/client.py +24 -2
- fcloud_sdk-0.3.0/src/fcloud/client_billing.py +105 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/config.py +24 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/direct.py +13 -5
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/errors.py +15 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/image.py +60 -12
- fcloud_sdk-0.3.0/src/fcloud/modal_compat.py +306 -0
- fcloud_sdk-0.3.0/src/fcloud/modal_objects.py +137 -0
- fcloud_sdk-0.3.0/src/fcloud/modal_run.py +93 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/providers/requests_http.py +9 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/sweeps.py +2 -2
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/types.py +1 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/v2_connect.py +10 -2
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud_sdk.egg-info/PKG-INFO +70 -3
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud_sdk.egg-info/SOURCES.txt +16 -0
- fcloud_sdk-0.3.0/tests/test_credit_preflight.py +103 -0
- fcloud_sdk-0.3.0/tests/test_credits_cli.py +237 -0
- fcloud_sdk-0.3.0/tests/test_credits_errors.py +106 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_direct_bridge.py +129 -4
- fcloud_sdk-0.3.0/tests/test_download_promotion_retry.py +92 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_exec_attach_retry.py +23 -1
- fcloud_sdk-0.3.0/tests/test_map_bake_flag.py +26 -0
- fcloud_sdk-0.3.0/tests/test_modal_compat.py +171 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_rate_limit_ride.py +2 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_ride_cap.py +1 -1
- fcloud_sdk-0.3.0/tests/test_v2_connect_billing.py +52 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/CONTRIBUTING.md +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/LICENSE +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/MANIFEST.in +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/NOTICE +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/setup.cfg +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/autoresearch/SETUP.md +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/autoresearch/program.template.md +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/autoresearch.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/config_cmd.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/hardware.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/mount.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/output.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/registry.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/setup.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/cli/sweep_harvest.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/client_projects.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/client_sessions.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/client_volumes.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/fileset.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/job.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/login.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/providers/__init__.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/py.typed +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/session.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/setup_cmd.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/shell.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/tunnel.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/version.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/volume_wait.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud/volumes.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud_sdk.egg-info/dependency_links.txt +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud_sdk.egg-info/entry_points.txt +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud_sdk.egg-info/requires.txt +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/fcloud_sdk.egg-info/top_level.txt +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/src/foom/__init__.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/conftest.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_autoresearch.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_checkpoint_option.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_cli_args.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_cli_dispatch.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_cli_guards.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_cli_help.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_client.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_client_contracts_c.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_client_host_frames_contract.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_client_http_contract.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_client_telemetry.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_config.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_config_cmd.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_default_image.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_direct_fake_host.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_dotenv_precedence.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_download_volume_hint.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_emit_pid.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_fileset.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_first_run_login.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_job.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_job_ls.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_kill_records_first.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_legacy_shim.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_login.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_logs_unknown_pid.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_map_dag.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_map_default_image.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_map_window_flags.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_no_resume_gate.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_progress_narration.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_provider_env_default.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_queued_reason_render.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_rebuild_respawn.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_resume_volume_validation.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_run_migration.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_run_volume_collision.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_run_wait_flags.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_secret_resolution.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_session_truth.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_sessions_live_statuses.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_sessions_point_lookup.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_setup_cmd.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_shell_env.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_shell_interrupt.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_shell_volumes.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_skus.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_spend.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_stop_refused_classification.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_sweep_boost.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_sweep_cost_optin.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_sweep_harvest.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_sweep_lifecycle_ux.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_sweep_retry_and_kill_truth.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_sweep_status_view.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_sweeps_binding.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_tunnel.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_types.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_upload_s3_path.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_v2_connect.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_volume_cli_hardening.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_volume_download_delete.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_volume_preflight.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_volume_resolution.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_volume_wait.py +0 -0
- {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.0}/tests/test_wait.py +0 -0
|
@@ -6,6 +6,61 @@ versions follow [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.0] - 2026-09-16
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- `exec` no longer returns empty output for a command whose last write lands
|
|
13
|
+
at process exit. The host mirrors a process's durable log on a background
|
|
14
|
+
goroutine, so a read issued the instant the process reports "exited" can
|
|
15
|
+
beat that write and come back empty; the client was answering with that
|
|
16
|
+
empty read even though it had already drained the output off the live
|
|
17
|
+
stream. It now keeps whichever of the two saw more bytes. The symptom was
|
|
18
|
+
`stdout: ""` alongside `returncode: 0` — most often for a block-buffered
|
|
19
|
+
program, which flushes everything during interpreter teardown
|
|
20
|
+
microseconds before it dies. A training run's final `print(metrics)` is
|
|
21
|
+
exactly that shape.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- `fcloud map --no-bake` opts a sweep out of canary auto-bake. By default,
|
|
25
|
+
packages the canary pip-installs are baked into the sweep's image before
|
|
26
|
+
the fan-out; the canary pass line now names them and points at
|
|
27
|
+
`image.pip` in `fcloud.json` as the permanent home.
|
|
28
|
+
- `fcloud credits`: the prepaid wallet from the terminal. `fcloud credits`
|
|
29
|
+
shows the posted balance, what is available after unbilled usage,
|
|
30
|
+
auto-refill state and the saved card; `fcloud credits buy <usd>` opens a
|
|
31
|
+
hosted checkout and waits for the purchase to post; `fcloud credits
|
|
32
|
+
autorefill on|off|set` changes the top-up rule (turning it off warns that
|
|
33
|
+
work stops at $0 and needs a confirmation); `fcloud credits statement`
|
|
34
|
+
lists ledger rows with a running balance.
|
|
35
|
+
- `InsufficientCreditError` (HTTP 402, `code: insufficient_credit`) — raised
|
|
36
|
+
when the prepaid balance is exhausted, and mapped to **exit code 4** in
|
|
37
|
+
the CLI with the top-up command named. It is a sibling of `FcloudError`,
|
|
38
|
+
not of `PaymentOverdueError`: nothing is owed, so the remedy is to add
|
|
39
|
+
credit, not to settle a bill. Code that catches `PaymentOverdueError`
|
|
40
|
+
alone will not catch it.
|
|
41
|
+
- `Client.credit_balance()`, `credit_ledger()`, `set_autorefill()` and
|
|
42
|
+
`start_credit_checkout()`, with typed payloads in `fcloud.billing_types`.
|
|
43
|
+
- A low-balance warning before `exec`/`run`/`shell`/`create`/`job run`/`map`
|
|
44
|
+
provision anything, shown once per process and only when nothing will top
|
|
45
|
+
the account up. Best-effort: a failed billing read never blocks a run.
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
- An out-of-credit refusal is no longer reported as a missed payment. Both
|
|
49
|
+
error paths now branch on the dispatcher's `code` before falling back on
|
|
50
|
+
the bare 402 status, so `payment_overdue`, `insufficient_credit` and an
|
|
51
|
+
older dispatcher's codeless 402 are told apart.
|
|
52
|
+
- A session closed for want of credit says so ("stopped: out of credits")
|
|
53
|
+
and exits 4, instead of exiting 75 as if it were briefly unreachable.
|
|
54
|
+
- `fcloud sweep` offers the fast lane only for waits more capacity can fix.
|
|
55
|
+
Tasks blocked on billing get a top-up hint — boosting past that block
|
|
56
|
+
buys nothing.
|
|
57
|
+
- Python 3.9 is now supported (`requires-python >= 3.9`), and 3.14 is
|
|
58
|
+
tested. The README has a support grid; CI runs the suite on 3.9 through
|
|
59
|
+
3.14. The installer script falls back to `uv` instead of `pipx`/`pip`
|
|
60
|
+
when the system Python is older than 3.9.
|
|
61
|
+
- The CLI no longer prints urllib3's LibreSSL warning on macOS's bundled
|
|
62
|
+
Python.
|
|
63
|
+
|
|
9
64
|
## [0.2.1] - 2026-09-14
|
|
10
65
|
|
|
11
66
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fcloud-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python SDK and CLI for the fcloud GPU compute platform
|
|
5
5
|
Author: fcloud
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -11,14 +11,16 @@ Classifier: Intended Audience :: Developers
|
|
|
11
11
|
Classifier: Intended Audience :: Science/Research
|
|
12
12
|
Classifier: Operating System :: OS Independent
|
|
13
13
|
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
15
|
Classifier: Programming Language :: Python :: 3.10
|
|
15
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
16
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
18
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
20
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
21
|
Classifier: Topic :: System :: Distributed Computing
|
|
20
22
|
Classifier: Typing :: Typed
|
|
21
|
-
Requires-Python: >=3.
|
|
23
|
+
Requires-Python: >=3.9
|
|
22
24
|
Description-Content-Type: text/markdown
|
|
23
25
|
License-File: LICENSE
|
|
24
26
|
License-File: NOTICE
|
|
@@ -39,7 +41,7 @@ hosts, run commands and scripts on them, keep a persistent workspace between
|
|
|
39
41
|
runs, move files in and out, and drive long-running or batch jobs — from a
|
|
40
42
|
terminal, from Python, or from an AI coding agent.
|
|
41
43
|
|
|
42
|
-
Requires Python 3.
|
|
44
|
+
Requires Python 3.9+ (see [Python versions](#python-versions)).
|
|
43
45
|
|
|
44
46
|
## Install
|
|
45
47
|
|
|
@@ -56,6 +58,28 @@ pip install fcloud-sdk
|
|
|
56
58
|
fcloud login
|
|
57
59
|
```
|
|
58
60
|
|
|
61
|
+
fcloud is prepaid — `fcloud credits buy 25` before your first run, and
|
|
62
|
+
`fcloud credits` to see the balance.
|
|
63
|
+
|
|
64
|
+
### Python versions
|
|
65
|
+
|
|
66
|
+
Every row runs the full test suite in CI on every push (the matrix in
|
|
67
|
+
`.github/workflows/unit-tests.yml` is the source of truth; a CI check fails
|
|
68
|
+
if `pyproject.toml`'s classifiers drift from it).
|
|
69
|
+
|
|
70
|
+
| Python | Status |
|
|
71
|
+
|--------|--------|
|
|
72
|
+
| 3.14 | supported |
|
|
73
|
+
| 3.13 | supported |
|
|
74
|
+
| 3.12 | supported |
|
|
75
|
+
| 3.11 | supported |
|
|
76
|
+
| 3.10 | supported |
|
|
77
|
+
| 3.9 | supported (macOS command-line-tools Python, Debian 11) |
|
|
78
|
+
| 3.8 and older | not supported; the installer script bootstraps `uv`, which fetches a supported Python |
|
|
79
|
+
|
|
80
|
+
The installer only uses your system Python (`pipx`/`pip`) when it is 3.9 or
|
|
81
|
+
newer; otherwise it installs `uv`, which brings its own.
|
|
82
|
+
|
|
59
83
|
## Setup
|
|
60
84
|
|
|
61
85
|
`fcloud login` opens https://fcloud-home.vercel.app to sign in (Google or
|
|
@@ -188,6 +212,49 @@ with project.session(sku="gpu_1x_l4") as s:
|
|
|
188
212
|
|
|
189
213
|
Errors raise `fcloud.FcloudError` (or a subclass such as `PaymentOverdueError`).
|
|
190
214
|
|
|
215
|
+
## Porting from Modal
|
|
216
|
+
|
|
217
|
+
`fcloud` exposes a Modal-compatible surface, so most Modal scripts port with an
|
|
218
|
+
import rename:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
import fcloud as modal # was: import modal
|
|
222
|
+
|
|
223
|
+
app = modal.App("demo")
|
|
224
|
+
image = modal.Image.debian_slim().pip_install("torch", "numpy")
|
|
225
|
+
|
|
226
|
+
@app.function(image=image, gpu="H100", timeout=600)
|
|
227
|
+
def train(steps: int) -> float:
|
|
228
|
+
...
|
|
229
|
+
|
|
230
|
+
@app.cls(gpu="L4", volumes={"/data": modal.Volume.from_name("weights", create_if_missing=True)})
|
|
231
|
+
class Model:
|
|
232
|
+
@modal.enter()
|
|
233
|
+
def load(self): ...
|
|
234
|
+
@modal.method()
|
|
235
|
+
def predict(self, x): ...
|
|
236
|
+
|
|
237
|
+
@app.local_entrypoint()
|
|
238
|
+
def main(steps: int = 100):
|
|
239
|
+
print(train.remote(steps))
|
|
240
|
+
print(Model().predict.remote(1))
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Run it with `fcloud run demo.py [--steps 500]` (the Modal-style `demo.py::name`
|
|
244
|
+
picks an entrypoint or function). Each `(gpu, image, volumes)` combination gets
|
|
245
|
+
one warm session; `.remote()` pickles the args, runs the function on the host
|
|
246
|
+
and returns the pickled result, streaming stdout back live.
|
|
247
|
+
|
|
248
|
+
Supported: `App`, `@app.function` / `@app.cls` / `@app.local_entrypoint`,
|
|
249
|
+
`.remote()` / `.spawn()` / `.map()`, `Image.*` (varargs or list),
|
|
250
|
+
`Volume.from_name`, `Secret.from_dict` / `from_dotenv` / `from_local_environ`,
|
|
251
|
+
`gpu="H100"`, `"A100-80GB:8"`, etc. `cpu=`, `memory=`, `retries=` and similar
|
|
252
|
+
options are accepted and ignored with a warning.
|
|
253
|
+
|
|
254
|
+
Not supported: web endpoints, `Dict` / `Queue`, schedules, sandboxes,
|
|
255
|
+
`Secret.from_name` (no hosted secret store), `modal deploy`. `.map()` runs
|
|
256
|
+
inputs sequentially on one session; use `fcloud map` for real fan-out.
|
|
257
|
+
|
|
191
258
|
## Configuration
|
|
192
259
|
|
|
193
260
|
API key, in order of precedence:
|
|
@@ -5,7 +5,7 @@ hosts, run commands and scripts on them, keep a persistent workspace between
|
|
|
5
5
|
runs, move files in and out, and drive long-running or batch jobs — from a
|
|
6
6
|
terminal, from Python, or from an AI coding agent.
|
|
7
7
|
|
|
8
|
-
Requires Python 3.
|
|
8
|
+
Requires Python 3.9+ (see [Python versions](#python-versions)).
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
@@ -22,6 +22,28 @@ pip install fcloud-sdk
|
|
|
22
22
|
fcloud login
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
fcloud is prepaid — `fcloud credits buy 25` before your first run, and
|
|
26
|
+
`fcloud credits` to see the balance.
|
|
27
|
+
|
|
28
|
+
### Python versions
|
|
29
|
+
|
|
30
|
+
Every row runs the full test suite in CI on every push (the matrix in
|
|
31
|
+
`.github/workflows/unit-tests.yml` is the source of truth; a CI check fails
|
|
32
|
+
if `pyproject.toml`'s classifiers drift from it).
|
|
33
|
+
|
|
34
|
+
| Python | Status |
|
|
35
|
+
|--------|--------|
|
|
36
|
+
| 3.14 | supported |
|
|
37
|
+
| 3.13 | supported |
|
|
38
|
+
| 3.12 | supported |
|
|
39
|
+
| 3.11 | supported |
|
|
40
|
+
| 3.10 | supported |
|
|
41
|
+
| 3.9 | supported (macOS command-line-tools Python, Debian 11) |
|
|
42
|
+
| 3.8 and older | not supported; the installer script bootstraps `uv`, which fetches a supported Python |
|
|
43
|
+
|
|
44
|
+
The installer only uses your system Python (`pipx`/`pip`) when it is 3.9 or
|
|
45
|
+
newer; otherwise it installs `uv`, which brings its own.
|
|
46
|
+
|
|
25
47
|
## Setup
|
|
26
48
|
|
|
27
49
|
`fcloud login` opens https://fcloud-home.vercel.app to sign in (Google or
|
|
@@ -154,6 +176,49 @@ with project.session(sku="gpu_1x_l4") as s:
|
|
|
154
176
|
|
|
155
177
|
Errors raise `fcloud.FcloudError` (or a subclass such as `PaymentOverdueError`).
|
|
156
178
|
|
|
179
|
+
## Porting from Modal
|
|
180
|
+
|
|
181
|
+
`fcloud` exposes a Modal-compatible surface, so most Modal scripts port with an
|
|
182
|
+
import rename:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
import fcloud as modal # was: import modal
|
|
186
|
+
|
|
187
|
+
app = modal.App("demo")
|
|
188
|
+
image = modal.Image.debian_slim().pip_install("torch", "numpy")
|
|
189
|
+
|
|
190
|
+
@app.function(image=image, gpu="H100", timeout=600)
|
|
191
|
+
def train(steps: int) -> float:
|
|
192
|
+
...
|
|
193
|
+
|
|
194
|
+
@app.cls(gpu="L4", volumes={"/data": modal.Volume.from_name("weights", create_if_missing=True)})
|
|
195
|
+
class Model:
|
|
196
|
+
@modal.enter()
|
|
197
|
+
def load(self): ...
|
|
198
|
+
@modal.method()
|
|
199
|
+
def predict(self, x): ...
|
|
200
|
+
|
|
201
|
+
@app.local_entrypoint()
|
|
202
|
+
def main(steps: int = 100):
|
|
203
|
+
print(train.remote(steps))
|
|
204
|
+
print(Model().predict.remote(1))
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Run it with `fcloud run demo.py [--steps 500]` (the Modal-style `demo.py::name`
|
|
208
|
+
picks an entrypoint or function). Each `(gpu, image, volumes)` combination gets
|
|
209
|
+
one warm session; `.remote()` pickles the args, runs the function on the host
|
|
210
|
+
and returns the pickled result, streaming stdout back live.
|
|
211
|
+
|
|
212
|
+
Supported: `App`, `@app.function` / `@app.cls` / `@app.local_entrypoint`,
|
|
213
|
+
`.remote()` / `.spawn()` / `.map()`, `Image.*` (varargs or list),
|
|
214
|
+
`Volume.from_name`, `Secret.from_dict` / `from_dotenv` / `from_local_environ`,
|
|
215
|
+
`gpu="H100"`, `"A100-80GB:8"`, etc. `cpu=`, `memory=`, `retries=` and similar
|
|
216
|
+
options are accepted and ignored with a warning.
|
|
217
|
+
|
|
218
|
+
Not supported: web endpoints, `Dict` / `Queue`, schedules, sandboxes,
|
|
219
|
+
`Secret.from_name` (no hosted secret store), `modal deploy`. `.map()` runs
|
|
220
|
+
inputs sequentially on one session; use `fcloud map` for real fan-out.
|
|
221
|
+
|
|
157
222
|
## Configuration
|
|
158
223
|
|
|
159
224
|
API key, in order of precedence:
|
|
@@ -88,6 +88,8 @@ ignored.
|
|
|
88
88
|
| ---------------------- | ----------------------------------------- |
|
|
89
89
|
| `fcloud health` | Check API connectivity |
|
|
90
90
|
| `fcloud set_token <key>` | Save API key |
|
|
91
|
+
| `fcloud credits` | Prepaid balance, auto-refill, card |
|
|
92
|
+
| `fcloud credits buy <usd>` | Buy credit (opens hosted checkout) |
|
|
91
93
|
|
|
92
94
|
|
|
93
95
|
### Execution
|
|
@@ -987,6 +989,16 @@ fcloud wait s-abc123 "$PID" --timeout 0
|
|
|
987
989
|
> alive (`fcloud logs <sid>` lists it as `running`) rather than assuming `rc=75`
|
|
988
990
|
> means your job is gone.
|
|
989
991
|
>
|
|
992
|
+
> **Out of credit (`rc=4`).** A 402 with `code: insufficient_credit` means the
|
|
993
|
+
> prepaid balance is exhausted: sessions are closed and queued jobs canceled.
|
|
994
|
+
> **This is not retryable** — retrying returns the same refusal until credit
|
|
995
|
+
> lands. Distinguish it from `rc=3` (`payment_overdue`, a failed payment on a
|
|
996
|
+
> bill) and from `rc=75` (transient unreachability, which *is* retryable). The
|
|
997
|
+
> remedy is `fcloud credits buy 25` or `fcloud credits autorefill on`; the block
|
|
998
|
+
> clears about a minute after the credit posts. In the SDK it raises
|
|
999
|
+
> `fcloud.InsufficientCreditError`, a sibling of `PaymentOverdueError` — code
|
|
1000
|
+
> that catches only `PaymentOverdueError` will not catch it.
|
|
1001
|
+
>
|
|
990
1002
|
> **Migration rides honor `--wait`.** When a foreground `exec` rides a
|
|
991
1003
|
> checkpoint/restore, the ride is bounded by your `--wait` budget — it never
|
|
992
1004
|
> blocks past it (`--wait 0` disables the bound; the ride then uses the full
|
|
@@ -1080,7 +1092,10 @@ fcloud exec --on s-abc123 /workspace/venv/bin/python train.py
|
|
|
1080
1092
|
```
|
|
1081
1093
|
|
|
1082
1094
|
Better yet, put packages in the image spec (`pip_install([...])`) so every
|
|
1083
|
-
resume rebuilds them from the content-addressed image cache.
|
|
1095
|
+
resume rebuilds them from the content-addressed image cache. A sweep whose
|
|
1096
|
+
canary pip-installs packages gets them baked into its image automatically
|
|
1097
|
+
before the fan-out (the pass line names them; `--no-bake` disables) — but
|
|
1098
|
+
the pins still belong in `fcloud.json` so the *next* sweep never pays them.
|
|
1084
1099
|
|
|
1085
1100
|
---
|
|
1086
1101
|
|
|
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fcloud-sdk"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Python SDK and CLI for the fcloud GPU compute platform"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
11
11
|
license-files = ["LICENSE", "NOTICE"]
|
|
12
12
|
authors = [{ name = "fcloud" }]
|
|
13
|
-
requires-python = ">=3.
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
14
|
keywords = ["gpu", "cloud", "compute", "ml", "training", "cli"]
|
|
15
15
|
classifiers = [
|
|
16
16
|
"Development Status :: 3 - Alpha",
|
|
@@ -19,10 +19,12 @@ classifiers = [
|
|
|
19
19
|
"Intended Audience :: Science/Research",
|
|
20
20
|
"Operating System :: OS Independent",
|
|
21
21
|
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
22
23
|
"Programming Language :: Python :: 3.10",
|
|
23
24
|
"Programming Language :: Python :: 3.11",
|
|
24
25
|
"Programming Language :: Python :: 3.12",
|
|
25
26
|
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Programming Language :: Python :: 3.14",
|
|
26
28
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
29
|
"Topic :: System :: Distributed Computing",
|
|
28
30
|
"Typing :: Typed",
|
|
@@ -61,13 +63,14 @@ testpaths = ["tests"]
|
|
|
61
63
|
|
|
62
64
|
[tool.ruff]
|
|
63
65
|
line-length = 100
|
|
64
|
-
target-version = "
|
|
66
|
+
target-version = "py39"
|
|
65
67
|
|
|
66
68
|
[tool.ruff.lint]
|
|
67
|
-
select = ["E", "F", "W", "I", "UP", "B"]
|
|
69
|
+
select = ["E", "F", "W", "I", "UP", "B", "FA"]
|
|
68
70
|
ignore = ["E741"]
|
|
69
71
|
|
|
70
72
|
[tool.mypy]
|
|
73
|
+
# mypy >= 1.19 cannot target 3.9; ruff (py39) and the CI 3.9 job cover the floor.
|
|
71
74
|
python_version = "3.10"
|
|
72
75
|
files = ["src/fcloud"]
|
|
73
76
|
ignore_missing_imports = true
|
|
@@ -88,6 +88,8 @@ ignored.
|
|
|
88
88
|
| ---------------------- | ----------------------------------------- |
|
|
89
89
|
| `fcloud health` | Check API connectivity |
|
|
90
90
|
| `fcloud set_token <key>` | Save API key |
|
|
91
|
+
| `fcloud credits` | Prepaid balance, auto-refill, card |
|
|
92
|
+
| `fcloud credits buy <usd>` | Buy credit (opens hosted checkout) |
|
|
91
93
|
|
|
92
94
|
|
|
93
95
|
### Execution
|
|
@@ -987,6 +989,16 @@ fcloud wait s-abc123 "$PID" --timeout 0
|
|
|
987
989
|
> alive (`fcloud logs <sid>` lists it as `running`) rather than assuming `rc=75`
|
|
988
990
|
> means your job is gone.
|
|
989
991
|
>
|
|
992
|
+
> **Out of credit (`rc=4`).** A 402 with `code: insufficient_credit` means the
|
|
993
|
+
> prepaid balance is exhausted: sessions are closed and queued jobs canceled.
|
|
994
|
+
> **This is not retryable** — retrying returns the same refusal until credit
|
|
995
|
+
> lands. Distinguish it from `rc=3` (`payment_overdue`, a failed payment on a
|
|
996
|
+
> bill) and from `rc=75` (transient unreachability, which *is* retryable). The
|
|
997
|
+
> remedy is `fcloud credits buy 25` or `fcloud credits autorefill on`; the block
|
|
998
|
+
> clears about a minute after the credit posts. In the SDK it raises
|
|
999
|
+
> `fcloud.InsufficientCreditError`, a sibling of `PaymentOverdueError` — code
|
|
1000
|
+
> that catches only `PaymentOverdueError` will not catch it.
|
|
1001
|
+
>
|
|
990
1002
|
> **Migration rides honor `--wait`.** When a foreground `exec` rides a
|
|
991
1003
|
> checkpoint/restore, the ride is bounded by your `--wait` budget — it never
|
|
992
1004
|
> blocks past it (`--wait 0` disables the bound; the ride then uses the full
|
|
@@ -1080,7 +1092,10 @@ fcloud exec --on s-abc123 /workspace/venv/bin/python train.py
|
|
|
1080
1092
|
```
|
|
1081
1093
|
|
|
1082
1094
|
Better yet, put packages in the image spec (`pip_install([...])`) so every
|
|
1083
|
-
resume rebuilds them from the content-addressed image cache.
|
|
1095
|
+
resume rebuilds them from the content-addressed image cache. A sweep whose
|
|
1096
|
+
canary pip-installs packages gets them baked into its image automatically
|
|
1097
|
+
before the fan-out (the pass line names them; `--no-bake` disables) — but
|
|
1098
|
+
the pins still belong in `fcloud.json` so the *next* sweep never pays them.
|
|
1084
1099
|
|
|
1085
1100
|
---
|
|
1086
1101
|
|
|
@@ -22,6 +22,7 @@ import fcloud._legacy_env # noqa: F401 — must run before any env read below
|
|
|
22
22
|
from fcloud.client import (
|
|
23
23
|
Client,
|
|
24
24
|
FcloudError,
|
|
25
|
+
InsufficientCreditError,
|
|
25
26
|
NoApiKeyError,
|
|
26
27
|
PaymentOverdueError,
|
|
27
28
|
SessionNotFoundError,
|
|
@@ -37,6 +38,16 @@ from fcloud.errors import (
|
|
|
37
38
|
)
|
|
38
39
|
from fcloud.image import Image
|
|
39
40
|
from fcloud.job import Job # noqa: F401
|
|
41
|
+
from fcloud.modal_compat import ( # Modal-compatible surface: `import fcloud as modal`
|
|
42
|
+
App,
|
|
43
|
+
RemoteError,
|
|
44
|
+
Secret,
|
|
45
|
+
Volume,
|
|
46
|
+
enter,
|
|
47
|
+
exit, # noqa: A004 — mirrors modal.exit
|
|
48
|
+
method,
|
|
49
|
+
parameter,
|
|
50
|
+
)
|
|
40
51
|
from fcloud.session import Project, Session
|
|
41
52
|
from fcloud.sweeps import MapSpecError, SweepHandle # noqa: F401
|
|
42
53
|
from fcloud.types import (
|
|
@@ -61,6 +72,15 @@ __all__ = [
|
|
|
61
72
|
"Image",
|
|
62
73
|
"set_token",
|
|
63
74
|
"get_token",
|
|
75
|
+
# Modal-compatible surface
|
|
76
|
+
"App",
|
|
77
|
+
"Secret",
|
|
78
|
+
"Volume",
|
|
79
|
+
"enter",
|
|
80
|
+
"exit",
|
|
81
|
+
"method",
|
|
82
|
+
"parameter",
|
|
83
|
+
"RemoteError",
|
|
64
84
|
# Handles
|
|
65
85
|
"Project",
|
|
66
86
|
"Session",
|
|
@@ -76,6 +96,7 @@ __all__ = [
|
|
|
76
96
|
"ClientTooOldError",
|
|
77
97
|
"FcloudError",
|
|
78
98
|
"FcloudRuntimeError",
|
|
99
|
+
"InsufficientCreditError",
|
|
79
100
|
"MapSpecError",
|
|
80
101
|
"NoApiKeyError",
|
|
81
102
|
"PaymentOverdueError",
|
|
@@ -50,6 +50,7 @@ from fcloud.cli.context import ( # noqa: F401
|
|
|
50
50
|
_resolve_project,
|
|
51
51
|
_resolve_volume_mounts,
|
|
52
52
|
)
|
|
53
|
+
from fcloud.cli.credits import cmd_credits # noqa: F401
|
|
53
54
|
from fcloud.cli.exec_cmd import _parse_exec_args, cmd_exec # noqa: F401
|
|
54
55
|
from fcloud.cli.files import ( # noqa: F401
|
|
55
56
|
_download_file,
|
|
@@ -99,6 +99,9 @@ _INLINE_BATCH_BYTES = 8 * 1024 * 1024
|
|
|
99
99
|
# (connect, read) timeouts for direct S3 PUT/GET. The long read budget
|
|
100
100
|
# covers multi-hundred-MB transfers.
|
|
101
101
|
_BLOB_HTTP_TIMEOUT = (30, 3600)
|
|
102
|
+
# How long _download_blob rides out 503 promotion_pending (a
|
|
103
|
+
# region-local blob still promoting into R2) before giving up.
|
|
104
|
+
_BLOB_PROMOTION_WAIT_S = 300.0
|
|
102
105
|
# Pre-signed URLs expire (dispatcher TTL: 15 min) and the dispatcher caps
|
|
103
106
|
# one presign request at 1000 hashes — so URLs are minted in small
|
|
104
107
|
# per-chunk batches right before each chunk's PUTs. One up-front batch
|
|
@@ -333,6 +336,43 @@ def _config_from_env() -> _BridgeConfig:
|
|
|
333
336
|
return _BridgeConfig(dispatcher_url=resolve_url(None))
|
|
334
337
|
|
|
335
338
|
|
|
339
|
+
def _prefer_complete_output(
|
|
340
|
+
durable: ExecResult, captured: ExecResult,
|
|
341
|
+
) -> ExecResult:
|
|
342
|
+
"""Answer with whichever read saw more of the process's output.
|
|
343
|
+
|
|
344
|
+
The durable log is authoritative for retention accounting — it is the
|
|
345
|
+
host's own copy, and it holds output this client never streamed (frames
|
|
346
|
+
shed to a slow subscriber, a reconnect mid-run). But the host mirrors it
|
|
347
|
+
on a background goroutine that nothing joins against process exit, so a
|
|
348
|
+
read issued the instant `poll` reports "exited" can beat that write and
|
|
349
|
+
come back empty. That is not rare: it is the normal shape of a
|
|
350
|
+
block-buffered program, which flushes everything inside interpreter
|
|
351
|
+
teardown microseconds before the process dies. Returning that empty read
|
|
352
|
+
over bytes we already drained off the stream loses a user's last line —
|
|
353
|
+
the final `print(metrics)` of a training run.
|
|
354
|
+
|
|
355
|
+
So: never regress below what we already have. The host applies the same
|
|
356
|
+
rule to itself for combined reads (handle_request.go, "prefer it ...
|
|
357
|
+
whenever it has more bytes than the durable copy has flushed so far");
|
|
358
|
+
this is that rule for the per-stream reads the exec path makes.
|
|
359
|
+
|
|
360
|
+
A tie keeps the durable result, which carries the truthful truncation
|
|
361
|
+
and retention accounting.
|
|
362
|
+
"""
|
|
363
|
+
if captured.stdout_total_bytes > durable.stdout_total_bytes:
|
|
364
|
+
durable.stdout = captured.stdout
|
|
365
|
+
durable.stdout_truncated = captured.stdout_truncated
|
|
366
|
+
durable.stdout_total_bytes = captured.stdout_total_bytes
|
|
367
|
+
durable.stdout_retained_bytes = captured.stdout_retained_bytes
|
|
368
|
+
durable.stdout_range = captured.stdout_range
|
|
369
|
+
# stderr carries no byte accounting on ExecResult, so length is the only
|
|
370
|
+
# comparison available.
|
|
371
|
+
if len(captured.stderr) > len(durable.stderr):
|
|
372
|
+
durable.stderr = captured.stderr
|
|
373
|
+
return durable
|
|
374
|
+
|
|
375
|
+
|
|
336
376
|
class _DirectBridge:
|
|
337
377
|
"""Owns a background asyncio loop and one DirectClient per live
|
|
338
378
|
session. Every public method is sync — callers don't see the
|
|
@@ -1106,24 +1146,25 @@ class _DirectBridge:
|
|
|
1106
1146
|
captured_err = capture_err.result(
|
|
1107
1147
|
output_limit_bytes, output_range,
|
|
1108
1148
|
)
|
|
1109
|
-
|
|
1110
|
-
client,
|
|
1149
|
+
captured_result = self._captured_exec_result(
|
|
1111
1150
|
proc,
|
|
1112
1151
|
data,
|
|
1113
1152
|
start,
|
|
1114
|
-
|
|
1115
|
-
|
|
1153
|
+
"exited",
|
|
1154
|
+
captured,
|
|
1155
|
+
captured_err,
|
|
1116
1156
|
)
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
return self._captured_exec_result(
|
|
1157
|
+
durable = await self._durable_exec_result(
|
|
1158
|
+
client,
|
|
1120
1159
|
proc,
|
|
1121
1160
|
data,
|
|
1122
1161
|
start,
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
captured_err,
|
|
1162
|
+
output_limit_bytes,
|
|
1163
|
+
output_range,
|
|
1126
1164
|
)
|
|
1165
|
+
if durable is None:
|
|
1166
|
+
return captured_result
|
|
1167
|
+
return _prefer_complete_output(durable, captured_result)
|
|
1127
1168
|
if time.time() >= deadline:
|
|
1128
1169
|
await self._cancel_output_collection(collector)
|
|
1129
1170
|
captured = capture.result(output_limit_bytes, output_range)
|
|
@@ -1615,18 +1656,45 @@ class _DirectBridge:
|
|
|
1615
1656
|
|
|
1616
1657
|
def _download_blob(self, file_hash: str) -> bytes:
|
|
1617
1658
|
"""Fetch a content-addressed blob from S3 via a dispatcher-minted
|
|
1618
|
-
pre-signed GET URL.
|
|
1659
|
+
pre-signed GET URL.
|
|
1660
|
+
|
|
1661
|
+
A blob that lives only in a region-local bucket has no global (R2)
|
|
1662
|
+
object to sign yet: the presign call funds its promotion and, if
|
|
1663
|
+
that outlasts the server's wait budget, answers 503
|
|
1664
|
+
promotion_pending + Retry-After. That is the common case for a
|
|
1665
|
+
just-closed session's large outputs (small files come back inline
|
|
1666
|
+
over the WS and never take this path), so wait it out rather than
|
|
1667
|
+
surfacing it as a download failure."""
|
|
1668
|
+
import time as _time
|
|
1669
|
+
|
|
1619
1670
|
import requests as _requests
|
|
1620
1671
|
|
|
1621
1672
|
headers = _dispatcher_headers(self._cfg.api_key)
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1673
|
+
deadline = _time.monotonic() + _BLOB_PROMOTION_WAIT_S
|
|
1674
|
+
notified = False
|
|
1675
|
+
while True:
|
|
1676
|
+
resp = _requests.post(
|
|
1677
|
+
self._cfg.dispatcher_url + "/v1/blobs/download-url",
|
|
1678
|
+
json={"hashes": [file_hash]},
|
|
1679
|
+
headers=headers,
|
|
1680
|
+
timeout=30,
|
|
1681
|
+
)
|
|
1682
|
+
data = resp.json()
|
|
1683
|
+
_raise_if_client_too_old(resp.status_code, data)
|
|
1684
|
+
pending = (resp.status_code == 503
|
|
1685
|
+
and data.get("error") == "promotion_pending")
|
|
1686
|
+
if pending and _time.monotonic() < deadline:
|
|
1687
|
+
if not notified:
|
|
1688
|
+
print("fetching from cold storage — waiting for the file "
|
|
1689
|
+
"to be promoted…", file=sys.stderr)
|
|
1690
|
+
notified = True
|
|
1691
|
+
try:
|
|
1692
|
+
delay = float(resp.headers.get("Retry-After", "5"))
|
|
1693
|
+
except ValueError:
|
|
1694
|
+
delay = 5.0
|
|
1695
|
+
_time.sleep(min(max(delay, 1.0), 30.0))
|
|
1696
|
+
continue
|
|
1697
|
+
break
|
|
1630
1698
|
if resp.status_code >= 400 or "error" in data:
|
|
1631
1699
|
raise FcloudRuntimeError(
|
|
1632
1700
|
data.get("error")
|
|
@@ -7,6 +7,11 @@ spelling a value arrived under.
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import os
|
|
10
|
+
import warnings
|
|
11
|
+
|
|
12
|
+
# macOS's bundled Python 3.9 links LibreSSL, and urllib3 warns on every import.
|
|
13
|
+
# Nothing here needs TLS 1.3-era OpenSSL features; keep the CLI quiet.
|
|
14
|
+
warnings.filterwarnings("ignore", message="urllib3 v2 only supports OpenSSL")
|
|
10
15
|
|
|
11
16
|
# ponytail: drop once nobody exports FOOM_* any more.
|
|
12
17
|
for _k in [k for k in os.environ if k.startswith("FOOM_")]:
|