fcloud-sdk 0.2.1__tar.gz → 0.3.1__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.
Files changed (166) hide show
  1. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/CHANGELOG.md +99 -0
  2. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/PKG-INFO +70 -3
  3. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/README.md +66 -1
  4. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/SKILL.md +16 -1
  5. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/pyproject.toml +7 -4
  6. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/SKILL.md +16 -1
  7. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/__init__.py +21 -0
  8. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/__main__.py +1 -0
  9. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/_direct_bridge.py +87 -19
  10. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/_legacy_env.py +5 -0
  11. fcloud_sdk-0.3.1/src/fcloud/_modal_runner.py +101 -0
  12. fcloud_sdk-0.3.1/src/fcloud/billing_types.py +298 -0
  13. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/__init__.py +2 -0
  14. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/attach.py +43 -6
  15. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/common.py +2 -0
  16. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/console.py +3 -1
  17. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/context.py +49 -0
  18. fcloud_sdk-0.3.1/src/fcloud/cli/credits.py +283 -0
  19. fcloud_sdk-0.3.1/src/fcloud/cli/credits_buy.py +115 -0
  20. fcloud_sdk-0.3.1/src/fcloud/cli/credits_render.py +133 -0
  21. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/exec_cmd.py +4 -0
  22. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/files.py +23 -10
  23. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/help.py +3 -0
  24. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/interactive.py +7 -1
  25. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/job.py +5 -1
  26. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/main.py +18 -1
  27. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/migration.py +14 -1
  28. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/processes.py +8 -1
  29. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/run.py +16 -0
  30. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/sessions.py +5 -1
  31. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/ssh.py +2 -0
  32. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/sweep.py +41 -4
  33. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/sweep_watch.py +12 -2
  34. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/usage.py +2 -0
  35. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/volume.py +2 -0
  36. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/wait.py +2 -0
  37. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli_args.py +10 -1
  38. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/client.py +24 -2
  39. fcloud_sdk-0.3.1/src/fcloud/client_billing.py +112 -0
  40. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/config.py +30 -0
  41. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/direct.py +13 -5
  42. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/errors.py +15 -0
  43. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/image.py +60 -12
  44. fcloud_sdk-0.3.1/src/fcloud/modal_compat.py +306 -0
  45. fcloud_sdk-0.3.1/src/fcloud/modal_objects.py +137 -0
  46. fcloud_sdk-0.3.1/src/fcloud/modal_run.py +93 -0
  47. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/providers/requests_http.py +9 -0
  48. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/sweeps.py +2 -2
  49. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/types.py +1 -0
  50. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/v2_connect.py +10 -2
  51. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud_sdk.egg-info/PKG-INFO +70 -3
  52. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud_sdk.egg-info/SOURCES.txt +17 -0
  53. fcloud_sdk-0.3.1/tests/test_billing_types.py +93 -0
  54. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_client_http_contract.py +39 -0
  55. fcloud_sdk-0.3.1/tests/test_credit_preflight.py +103 -0
  56. fcloud_sdk-0.3.1/tests/test_credits_cli.py +450 -0
  57. fcloud_sdk-0.3.1/tests/test_credits_errors.py +106 -0
  58. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_direct_bridge.py +129 -4
  59. fcloud_sdk-0.3.1/tests/test_download_promotion_retry.py +92 -0
  60. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_exec_attach_retry.py +23 -1
  61. fcloud_sdk-0.3.1/tests/test_map_bake_flag.py +26 -0
  62. fcloud_sdk-0.3.1/tests/test_modal_compat.py +171 -0
  63. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_rate_limit_ride.py +2 -0
  64. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_ride_cap.py +1 -1
  65. fcloud_sdk-0.3.1/tests/test_v2_connect_billing.py +52 -0
  66. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/CONTRIBUTING.md +0 -0
  67. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/LICENSE +0 -0
  68. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/MANIFEST.in +0 -0
  69. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/NOTICE +0 -0
  70. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/setup.cfg +0 -0
  71. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/autoresearch/SETUP.md +0 -0
  72. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/autoresearch/program.template.md +0 -0
  73. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/autoresearch.py +0 -0
  74. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/config_cmd.py +0 -0
  75. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/hardware.py +0 -0
  76. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/mount.py +0 -0
  77. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/output.py +0 -0
  78. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/registry.py +0 -0
  79. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/setup.py +0 -0
  80. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/cli/sweep_harvest.py +0 -0
  81. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/client_projects.py +0 -0
  82. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/client_sessions.py +0 -0
  83. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/client_volumes.py +0 -0
  84. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/fileset.py +0 -0
  85. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/job.py +0 -0
  86. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/login.py +0 -0
  87. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/providers/__init__.py +0 -0
  88. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/py.typed +0 -0
  89. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/session.py +0 -0
  90. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/setup_cmd.py +0 -0
  91. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/shell.py +0 -0
  92. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/tunnel.py +0 -0
  93. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/version.py +0 -0
  94. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/volume_wait.py +0 -0
  95. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud/volumes.py +0 -0
  96. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud_sdk.egg-info/dependency_links.txt +0 -0
  97. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud_sdk.egg-info/entry_points.txt +0 -0
  98. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud_sdk.egg-info/requires.txt +0 -0
  99. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/fcloud_sdk.egg-info/top_level.txt +0 -0
  100. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/src/foom/__init__.py +0 -0
  101. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/conftest.py +0 -0
  102. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_autoresearch.py +0 -0
  103. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_checkpoint_option.py +0 -0
  104. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_cli_args.py +0 -0
  105. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_cli_dispatch.py +0 -0
  106. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_cli_guards.py +0 -0
  107. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_cli_help.py +0 -0
  108. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_client.py +0 -0
  109. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_client_contracts_c.py +0 -0
  110. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_client_host_frames_contract.py +0 -0
  111. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_client_telemetry.py +0 -0
  112. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_config.py +0 -0
  113. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_config_cmd.py +0 -0
  114. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_default_image.py +0 -0
  115. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_direct_fake_host.py +0 -0
  116. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_dotenv_precedence.py +0 -0
  117. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_download_volume_hint.py +0 -0
  118. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_emit_pid.py +0 -0
  119. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_fileset.py +0 -0
  120. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_first_run_login.py +0 -0
  121. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_job.py +0 -0
  122. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_job_ls.py +0 -0
  123. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_kill_records_first.py +0 -0
  124. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_legacy_shim.py +0 -0
  125. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_login.py +0 -0
  126. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_logs_unknown_pid.py +0 -0
  127. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_map_dag.py +0 -0
  128. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_map_default_image.py +0 -0
  129. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_map_window_flags.py +0 -0
  130. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_no_resume_gate.py +0 -0
  131. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_progress_narration.py +0 -0
  132. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_provider_env_default.py +0 -0
  133. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_queued_reason_render.py +0 -0
  134. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_rebuild_respawn.py +0 -0
  135. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_resume_volume_validation.py +0 -0
  136. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_run_migration.py +0 -0
  137. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_run_volume_collision.py +0 -0
  138. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_run_wait_flags.py +0 -0
  139. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_secret_resolution.py +0 -0
  140. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_session_truth.py +0 -0
  141. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_sessions_live_statuses.py +0 -0
  142. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_sessions_point_lookup.py +0 -0
  143. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_setup_cmd.py +0 -0
  144. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_shell_env.py +0 -0
  145. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_shell_interrupt.py +0 -0
  146. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_shell_volumes.py +0 -0
  147. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_skus.py +0 -0
  148. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_spend.py +0 -0
  149. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_stop_refused_classification.py +0 -0
  150. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_sweep_boost.py +0 -0
  151. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_sweep_cost_optin.py +0 -0
  152. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_sweep_harvest.py +0 -0
  153. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_sweep_lifecycle_ux.py +0 -0
  154. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_sweep_retry_and_kill_truth.py +0 -0
  155. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_sweep_status_view.py +0 -0
  156. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_sweeps_binding.py +0 -0
  157. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_tunnel.py +0 -0
  158. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_types.py +0 -0
  159. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_upload_s3_path.py +0 -0
  160. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_v2_connect.py +0 -0
  161. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_volume_cli_hardening.py +0 -0
  162. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_volume_download_delete.py +0 -0
  163. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_volume_preflight.py +0 -0
  164. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_volume_resolution.py +0 -0
  165. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_volume_wait.py +0 -0
  166. {fcloud_sdk-0.2.1 → fcloud_sdk-0.3.1}/tests/test_wait.py +0 -0
@@ -6,6 +6,105 @@ versions follow [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.1] - 2026-09-22
10
+
11
+ ### Fixed
12
+ - `fcloud credits buy` no longer turns auto-refill on by default. It used to
13
+ consent on behalf of anyone who had never consented before, so buying less
14
+ than the auto-refill threshold enrolled the account and then charged the
15
+ saved card for the full top-up seconds later — `buy 10` against the default
16
+ $25 threshold cost $110. Consent is now opt-in via `--autorefill`, and
17
+ `--autorefill` also turns the setting back on for an account that had it
18
+ off, which consent alone cannot do. `--no-autorefill` is still accepted and
19
+ is now the default; passing both is an error.
20
+ - Before a purchase that will trigger a top-up, the CLI now says so and names
21
+ the amount. What triggers one is the balance a purchase *leaves behind*,
22
+ not its size, and the refill is sized to clear the threshold — so $10
23
+ against a $200 threshold charges the card a further $195, not the $50 the
24
+ setting says. Both numbers are worked out and printed before anything is
25
+ charged, including under `--yes`, and including for an account that was
26
+ already armed.
27
+ - `fcloud credits` no longer claims auto-refill "turns on with your first
28
+ purchase". It turns on with a purchase made with `--autorefill`, and the
29
+ status line now says so and names that command.
30
+ - `fcloud credits buy` no longer sends buyers to a dead address after paying.
31
+ The return URL was built from a domain baked in at release time, which
32
+ outlived its DNS record: the charge succeeded and the browser landed on a
33
+ 404. The CLI now names no return address and the dispatcher supplies one
34
+ from its own allowlist, which redeploys with the web app instead of going
35
+ stale in a published wheel. Setting `FCLOUD_WEB_URL` returns you to
36
+ `/dashboard`, the page that reads the result, rather than to
37
+ `/dashboard/billing`, which ignores it.
38
+
39
+ ### Changed
40
+ - `resolve_web_url()` returns `None` when no web URL is configured, and
41
+ `DEFAULT_WEB_URL` is gone. `Client.start_credit_checkout()` takes
42
+ `success_url` and `cancel_url` as optional and omits them from the request
43
+ when unset. Set `FCLOUD_WEB_URL` only to override where a buyer is returned
44
+ to. **Needs a dispatcher carrying the matching server change**; against an
45
+ older one, set `FCLOUD_WEB_URL` or buy from the dashboard.
46
+
47
+ ### Added
48
+ - `fcloud.billing_types.predicted_topup_micros()` — what auto-refill would
49
+ charge once a given purchase has posted. Mirrors the dispatcher's own
50
+ `topUpAmount` and daily-cap clamp so a confirmation can name the second
51
+ charge before the card is saved.
52
+
53
+ ## [0.3.0] - 2026-09-16
54
+
55
+ ### Fixed
56
+ - `exec` no longer returns empty output for a command whose last write lands
57
+ at process exit. The host mirrors a process's durable log on a background
58
+ goroutine, so a read issued the instant the process reports "exited" can
59
+ beat that write and come back empty; the client was answering with that
60
+ empty read even though it had already drained the output off the live
61
+ stream. It now keeps whichever of the two saw more bytes. The symptom was
62
+ `stdout: ""` alongside `returncode: 0` — most often for a block-buffered
63
+ program, which flushes everything during interpreter teardown
64
+ microseconds before it dies. A training run's final `print(metrics)` is
65
+ exactly that shape.
66
+
67
+ ### Added
68
+ - `fcloud map --no-bake` opts a sweep out of canary auto-bake. By default,
69
+ packages the canary pip-installs are baked into the sweep's image before
70
+ the fan-out; the canary pass line now names them and points at
71
+ `image.pip` in `fcloud.json` as the permanent home.
72
+ - `fcloud credits`: the prepaid wallet from the terminal. `fcloud credits`
73
+ shows the posted balance, what is available after unbilled usage,
74
+ auto-refill state and the saved card; `fcloud credits buy <usd>` opens a
75
+ hosted checkout and waits for the purchase to post; `fcloud credits
76
+ autorefill on|off|set` changes the top-up rule (turning it off warns that
77
+ work stops at $0 and needs a confirmation); `fcloud credits statement`
78
+ lists ledger rows with a running balance.
79
+ - `InsufficientCreditError` (HTTP 402, `code: insufficient_credit`) — raised
80
+ when the prepaid balance is exhausted, and mapped to **exit code 4** in
81
+ the CLI with the top-up command named. It is a sibling of `FcloudError`,
82
+ not of `PaymentOverdueError`: nothing is owed, so the remedy is to add
83
+ credit, not to settle a bill. Code that catches `PaymentOverdueError`
84
+ alone will not catch it.
85
+ - `Client.credit_balance()`, `credit_ledger()`, `set_autorefill()` and
86
+ `start_credit_checkout()`, with typed payloads in `fcloud.billing_types`.
87
+ - A low-balance warning before `exec`/`run`/`shell`/`create`/`job run`/`map`
88
+ provision anything, shown once per process and only when nothing will top
89
+ the account up. Best-effort: a failed billing read never blocks a run.
90
+
91
+ ### Changed
92
+ - An out-of-credit refusal is no longer reported as a missed payment. Both
93
+ error paths now branch on the dispatcher's `code` before falling back on
94
+ the bare 402 status, so `payment_overdue`, `insufficient_credit` and an
95
+ older dispatcher's codeless 402 are told apart.
96
+ - A session closed for want of credit says so ("stopped: out of credits")
97
+ and exits 4, instead of exiting 75 as if it were briefly unreachable.
98
+ - `fcloud sweep` offers the fast lane only for waits more capacity can fix.
99
+ Tasks blocked on billing get a top-up hint — boosting past that block
100
+ buys nothing.
101
+ - Python 3.9 is now supported (`requires-python >= 3.9`), and 3.14 is
102
+ tested. The README has a support grid; CI runs the suite on 3.9 through
103
+ 3.14. The installer script falls back to `uv` instead of `pipx`/`pip`
104
+ when the system Python is older than 3.9.
105
+ - The CLI no longer prints urllib3's LibreSSL warning on macOS's bundled
106
+ Python.
107
+
9
108
  ## [0.2.1] - 2026-09-14
10
109
 
11
110
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fcloud-sdk
3
- Version: 0.2.1
3
+ Version: 0.3.1
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.10
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.10+.
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.10+.
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.2.1"
7
+ version = "0.3.1"
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.10"
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 = "py310"
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
- durable = await self._durable_exec_result(
1110
- client,
1149
+ captured_result = self._captured_exec_result(
1111
1150
  proc,
1112
1151
  data,
1113
1152
  start,
1114
- output_limit_bytes,
1115
- output_range,
1153
+ "exited",
1154
+ captured,
1155
+ captured_err,
1116
1156
  )
1117
- if durable is not None:
1118
- return durable
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
- "exited",
1124
- captured,
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
- resp = _requests.post(
1623
- self._cfg.dispatcher_url + "/v1/blobs/download-url",
1624
- json={"hashes": [file_hash]},
1625
- headers=headers,
1626
- timeout=30,
1627
- )
1628
- data = resp.json()
1629
- _raise_if_client_too_old(resp.status_code, data)
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_")]: