fcloud-sdk 0.1.0__py3-none-any.whl

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 (63) hide show
  1. fcloud/SKILL.md +1185 -0
  2. fcloud/__init__.py +85 -0
  3. fcloud/__main__.py +150 -0
  4. fcloud/_direct_bridge.py +1645 -0
  5. fcloud/_legacy_env.py +14 -0
  6. fcloud/cli/__init__.py +23 -0
  7. fcloud/cli/attach.py +180 -0
  8. fcloud/cli/common.py +382 -0
  9. fcloud/cli/console.py +227 -0
  10. fcloud/cli/context.py +161 -0
  11. fcloud/cli/exec_cmd.py +201 -0
  12. fcloud/cli/files.py +280 -0
  13. fcloud/cli/hardware.py +83 -0
  14. fcloud/cli/help.py +150 -0
  15. fcloud/cli/interactive.py +104 -0
  16. fcloud/cli/job.py +300 -0
  17. fcloud/cli/main.py +140 -0
  18. fcloud/cli/migration.py +276 -0
  19. fcloud/cli/mount.py +150 -0
  20. fcloud/cli/output.py +17 -0
  21. fcloud/cli/processes.py +360 -0
  22. fcloud/cli/registry.py +28 -0
  23. fcloud/cli/run.py +318 -0
  24. fcloud/cli/sessions.py +592 -0
  25. fcloud/cli/setup.py +44 -0
  26. fcloud/cli/ssh.py +255 -0
  27. fcloud/cli/sweep.py +578 -0
  28. fcloud/cli/sweep_harvest.py +187 -0
  29. fcloud/cli/sweep_watch.py +104 -0
  30. fcloud/cli/volume.py +317 -0
  31. fcloud/cli/wait.py +314 -0
  32. fcloud/cli_args.py +348 -0
  33. fcloud/client.py +632 -0
  34. fcloud/client_projects.py +52 -0
  35. fcloud/client_sessions.py +111 -0
  36. fcloud/client_volumes.py +80 -0
  37. fcloud/config.py +404 -0
  38. fcloud/direct.py +535 -0
  39. fcloud/errors.py +102 -0
  40. fcloud/fileset.py +155 -0
  41. fcloud/image.py +483 -0
  42. fcloud/job.py +249 -0
  43. fcloud/providers/__init__.py +5 -0
  44. fcloud/providers/requests_http.py +59 -0
  45. fcloud/py.typed +0 -0
  46. fcloud/session.py +442 -0
  47. fcloud/setup_cmd.py +213 -0
  48. fcloud/shell.py +558 -0
  49. fcloud/sweeps.py +557 -0
  50. fcloud/tunnel.py +251 -0
  51. fcloud/types.py +302 -0
  52. fcloud/v2_connect.py +928 -0
  53. fcloud/version.py +33 -0
  54. fcloud/volume_wait.py +131 -0
  55. fcloud/volumes.py +204 -0
  56. fcloud_sdk-0.1.0.dist-info/METADATA +234 -0
  57. fcloud_sdk-0.1.0.dist-info/RECORD +63 -0
  58. fcloud_sdk-0.1.0.dist-info/WHEEL +5 -0
  59. fcloud_sdk-0.1.0.dist-info/entry_points.txt +3 -0
  60. fcloud_sdk-0.1.0.dist-info/licenses/LICENSE +202 -0
  61. fcloud_sdk-0.1.0.dist-info/licenses/NOTICE +4 -0
  62. fcloud_sdk-0.1.0.dist-info/top_level.txt +2 -0
  63. foom/__init__.py +36 -0
fcloud/cli/job.py ADDED
@@ -0,0 +1,300 @@
1
+ """`fcloud job`: run a command on an ephemeral session (no saved filesystem)."""
2
+
3
+ import sys
4
+ import time
5
+
6
+ from fcloud.cli import output
7
+ from fcloud.cli.common import (
8
+ _extract_flag,
9
+ _extract_secrets,
10
+ _extract_upload_options,
11
+ _parse_duration_seconds,
12
+ _parse_min_disk,
13
+ _parse_volume_mount,
14
+ _reject_volume_upload_collision,
15
+ _shape_exec_command,
16
+ )
17
+ from fcloud.cli.context import (
18
+ _resolve_min_disk_gb,
19
+ _resolve_project,
20
+ _resolve_volume_mounts,
21
+ )
22
+ from fcloud.cli.processes import cmd_logs
23
+ from fcloud.cli.registry import cmd
24
+ from fcloud.cli.sessions import _session_state
25
+ from fcloud.cli.wait import cmd_wait
26
+ from fcloud.client import Client
27
+
28
+ _JOB_LONG_HELP = """\
29
+ A job runs a command to completion on an EPHEMERAL session: its filesystem
30
+ is the image + --include files + volumes, and it is never persisted — the
31
+ session cannot be resumed, mounted, or downloaded after it closes. Write
32
+ durable outputs to a volume (--volume) or download them while the job runs;
33
+ the exit code and logs always survive (fcloud job logs).
34
+
35
+ Jobs are never checkpoint/migrated: if the host is lost mid-run (spot
36
+ preemption), --retries N re-runs the command on a fresh session.
37
+
38
+ --include PATH uploads a file or directory before the command starts (same
39
+ rules as `fcloud upload`: hidden entries, dependency dirs and symlinked files
40
+ are skipped unless --include-hidden / --follow-symlinks say otherwise).
41
+
42
+ `job run` returns once the command has exited AND its volumes are
43
+ committed — read them the moment it returns. If the host is unreachable at
44
+ that point the commit is deferred to its teardown replay, and the command
45
+ says so instead of returning a silent lie.
46
+
47
+ Examples:
48
+ fcloud job run --sku gpu_1x_l4 --volume results -- python train.py
49
+ fcloud job run --include ./src --env RUN=7 --detach -- python src/gen.py
50
+ fcloud job ls
51
+ fcloud job logs s-abc123 # durable output, live or after close
52
+ fcloud job wait s-abc123 # block until the job ends
53
+ fcloud job kill s-abc123
54
+
55
+ Sweep management moved to `fcloud sweep <status|logs|retry|cancel|wait>`."""
56
+
57
+
58
+ _JOB_SUBCOMMANDS = ("run", "ls", "logs", "wait", "kill")
59
+
60
+
61
+ @cmd("job", "fcloud job <run|ls|logs|wait|kill> …",
62
+ "Run a job: ephemeral session, no saved filesystem",
63
+ long_help=_JOB_LONG_HELP)
64
+ def cmd_job(args: list[str]) -> None:
65
+ if not args:
66
+ print("Usage: fcloud job <run|ls|logs|wait|kill> … "
67
+ "(see `fcloud help job`)", file=sys.stderr)
68
+ sys.exit(2)
69
+ sub, rest = args[0], args[1:]
70
+ if sub == "run":
71
+ _job_run(rest)
72
+ elif sub == "ls":
73
+ _job_ls(rest)
74
+ elif sub == "logs":
75
+ _job_logs(rest)
76
+ elif sub == "wait":
77
+ _job_wait(rest)
78
+ elif sub == "kill":
79
+ _job_kill(rest)
80
+ else:
81
+ print(f"Unknown job subcommand {sub!r}. Jobs: fcloud job "
82
+ f"<run|ls|logs|wait|kill>. Sweeps: fcloud sweep <name>.",
83
+ file=sys.stderr)
84
+ sys.exit(2)
85
+
86
+
87
+ def _job_run(args: list[str]) -> None:
88
+ try:
89
+ secrets, args = _extract_secrets(args)
90
+ except ValueError as exc:
91
+ print(f"Error: {exc}", file=sys.stderr)
92
+ sys.exit(2)
93
+ upload_opts, args = _extract_upload_options(args)
94
+ sku, min_disk, retries, detach = "", 0, 0, False
95
+ volumes: list[dict[str, str]] = []
96
+ include: list[str] = []
97
+ env: dict[str, str] = {}
98
+ cmd_args: list[str] = []
99
+ i = 0
100
+ while i < len(args):
101
+ a = args[i]
102
+ if a == "--":
103
+ cmd_args = args[i + 1:]
104
+ break
105
+ if a == "--sku" and i + 1 < len(args):
106
+ sku = args[i + 1]
107
+ i += 2
108
+ elif a == "--volume" and i + 1 < len(args):
109
+ volumes.append(_parse_volume_mount(args[i + 1]))
110
+ i += 2
111
+ elif a == "--include" and i + 1 < len(args):
112
+ include.append(args[i + 1])
113
+ i += 2
114
+ elif a == "--env" and i + 1 < len(args):
115
+ key, _, value = args[i + 1].partition("=")
116
+ env[key] = value
117
+ i += 2
118
+ elif a in ("--min-disk-gb", "--min-disk") and i + 1 < len(args):
119
+ try:
120
+ min_disk = _parse_min_disk(args[i + 1])
121
+ except ValueError as exc:
122
+ print(f"Error: {exc}", file=sys.stderr)
123
+ sys.exit(2)
124
+ i += 2
125
+ elif a == "--retries" and i + 1 < len(args):
126
+ retries = int(args[i + 1])
127
+ i += 2
128
+ elif a == "--detach":
129
+ detach = True
130
+ i += 1
131
+ elif a.startswith("--"):
132
+ print(f"Error: unknown job run option {a!r} (put the command "
133
+ f"after `--`)", file=sys.stderr)
134
+ sys.exit(2)
135
+ else:
136
+ cmd_args = args[i:]
137
+ break
138
+ continue
139
+ if not cmd_args:
140
+ print("Usage: fcloud job run [--sku SKU] [--volume NAME[:MOUNT]] "
141
+ "[--include PATH] [--include-hidden] [--follow-symlinks] "
142
+ "[--env K=V] [--secret KEY[=VALUE]] "
143
+ "[--retries N] [--detach] -- <command...>", file=sys.stderr)
144
+ sys.exit(2)
145
+ env.update(secrets)
146
+
147
+ client = Client()
148
+ project, default_sku = _resolve_project(client, sku=sku)
149
+ volume_mounts = _resolve_volume_mounts(volumes)
150
+ # --include uploads land at /workspace/<basename>: same collision
151
+ # hazard as `fcloud run` (BUG-1) — a mount there absorbs the upload.
152
+ from pathlib import Path
153
+ for inc in include:
154
+ _reject_volume_upload_collision(volume_mounts, Path(inc).resolve())
155
+ job = project.job(
156
+ _shape_exec_command(cmd_args),
157
+ sku=sku or default_sku,
158
+ volume_mounts=volume_mounts or None,
159
+ include=include,
160
+ include_hidden=upload_opts.include_hidden,
161
+ follow_symlinks=upload_opts.follow_symlinks,
162
+ env=env or None,
163
+ retries=retries,
164
+ min_disk_gb=_resolve_min_disk_gb(min_disk),
165
+ # Teardown status on stderr: the close that commits the job's
166
+ # volumes runs after the command exits, and it is not instant.
167
+ on_progress=lambda line: print(line, file=sys.stderr),
168
+ )
169
+ print(f"Job {job.session_id} (pid {job.process_id}) — ephemeral: "
170
+ f"outputs go to volumes/logs.", file=sys.stderr)
171
+ if detach:
172
+ if output._JSON_OUTPUT:
173
+ output._out({"session_id": job.session_id,
174
+ "process_id": job.process_id, "detached": True})
175
+ else:
176
+ print(f"Detached. Track: fcloud job logs {job.session_id} · "
177
+ f"fcloud job wait {job.session_id}", file=sys.stderr)
178
+ return
179
+ try:
180
+ for event in job.watch():
181
+ data = event.get("data", "")
182
+ if data:
183
+ sys.stdout.write(data)
184
+ sys.stdout.flush()
185
+ except KeyboardInterrupt:
186
+ print(f"\nDetached — the job keeps running. Track: "
187
+ f"fcloud job wait {job.session_id}", file=sys.stderr)
188
+ return
189
+ log = job.wait(poll_interval=2)
190
+ if job.attempts > 1:
191
+ print(f"(re-ran {job.attempts - 1} time(s) after host loss)",
192
+ file=sys.stderr)
193
+ if output._JSON_OUTPUT:
194
+ output._out({"session_id": job.session_id, "process_id": job.process_id,
195
+ "exit_code": log.exit_code, "status": job.status,
196
+ "attempts": job.attempts, "volume_sync": job.volume_sync})
197
+ if log.exit_code is None:
198
+ print("Job ended without an exit code (host lost; retries "
199
+ "exhausted).", file=sys.stderr)
200
+ sys.exit(1)
201
+ sys.exit(log.exit_code)
202
+
203
+
204
+ def _job_ls(args: list[str]) -> None:
205
+ show_all = "--all" in args
206
+ limit, _rest = _extract_flag([a for a in args if a != "--all"], "--limit")
207
+ limit_n = int(limit) if limit else 0
208
+ client = Client()
209
+ jobs = [s for s in client.list_sessions(status="all") if s.ephemeral]
210
+ if not show_all:
211
+ jobs = [s for s in jobs
212
+ if _session_state(s) not in
213
+ {"cold", "ended", "failed", "dead"}]
214
+ jobs.sort(key=lambda s: (s.last_active or s.created_at or ""),
215
+ reverse=True)
216
+ if limit_n > 0:
217
+ jobs = jobs[:limit_n]
218
+ if output._JSON_OUTPUT:
219
+ output._out({"jobs": [
220
+ {"session_id": s.session_id, "status": s.status,
221
+ "state": _session_state(s), "lifecycle": s.lifecycle,
222
+ "created_at": s.created_at}
223
+ for s in jobs]})
224
+ return
225
+ if not jobs:
226
+ print("No jobs. Run one with: fcloud job run -- <cmd>"
227
+ + ("" if show_all else " (--all includes finished jobs)"))
228
+ return
229
+ print(f"{'JOB':<22} {'STATE':<12} {'CREATED'}")
230
+ print("-" * 56)
231
+ for s in jobs:
232
+ print(f"{s.session_id:<22} {_session_state(s):<12} "
233
+ f"{s.created_at or ''}")
234
+
235
+
236
+ def _job_logs(args: list[str]) -> None:
237
+ if not args:
238
+ print("Usage: fcloud job logs <job-id> [--follow]", file=sys.stderr)
239
+ sys.exit(2)
240
+ cmd_logs(args)
241
+
242
+
243
+ def _job_wait(args: list[str]) -> None:
244
+ timeout, rest = _extract_flag(args, "--timeout")
245
+ if not rest:
246
+ print("Usage: fcloud job wait <job-id> [pid] [--timeout DUR]",
247
+ file=sys.stderr)
248
+ sys.exit(2)
249
+ if len(rest) >= 2:
250
+ cmd_wait(args)
251
+ return
252
+ # No pid: wait for the job's SESSION to reach a terminal state — the
253
+ # run-to-completion contract releases the session when the command
254
+ # exits, so "session ended" == "job done".
255
+ sid = rest[0]
256
+ deadline = (
257
+ time.monotonic() + _parse_duration_seconds(timeout)
258
+ ) if timeout else None
259
+ client = Client()
260
+ while True:
261
+ info = client.describe_session(sid)
262
+ stage = (info.lifecycle or info.status or "").lower()
263
+ if stage in ("closed", "failed", "dead"):
264
+ if output._JSON_OUTPUT:
265
+ output._out({"session_id": sid, "lifecycle": stage})
266
+ else:
267
+ print(f"Job {sid} ended ({stage}).")
268
+ sys.exit(0 if stage == "closed" else 1)
269
+ if deadline is not None and time.monotonic() >= deadline:
270
+ print(f"Job {sid} still {stage} after {timeout}.",
271
+ file=sys.stderr)
272
+ sys.exit(1)
273
+ time.sleep(3)
274
+
275
+
276
+ def _job_kill(args: list[str]) -> None:
277
+ if not args:
278
+ print("Usage: fcloud job kill <job-id>", file=sys.stderr)
279
+ sys.exit(2)
280
+ sid = args[0]
281
+ client = Client()
282
+ # Idempotent like `fcloud stop`: killing an already-ended job is the no-op
283
+ # the caller wanted — but say so instead of claiming a fresh kill.
284
+ try:
285
+ close_result = client.close_session(sid) or {}
286
+ except Exception as exc:
287
+ if "session_not_active" not in str(exc):
288
+ raise
289
+ close_result = {"status": "already_ended"}
290
+ status = close_result.get("status", "")
291
+ if output._JSON_OUTPUT:
292
+ output._out({"ok": True, "session_id": sid, "status": status})
293
+ return
294
+ if status == "already_ended":
295
+ print(f"Job {sid} already ended (no action needed).")
296
+ elif status in ("close_in_progress", "close_deferred"):
297
+ print(f"Job {sid} is already shutting down ({status}).")
298
+ else:
299
+ print(f"Job {sid} killed. Workspace discarded; volume commits and "
300
+ f"logs survive (fcloud job logs {sid}).")
fcloud/cli/main.py ADDED
@@ -0,0 +1,140 @@
1
+ """CLI entry point: global flags, dispatch, top-level error handling."""
2
+
3
+ import sys
4
+
5
+ from fcloud.cli import output
6
+ from fcloud.cli.help import print_command_help, print_help
7
+ from fcloud.cli.registry import COMMANDS
8
+ from fcloud.cli_args import SPECS, ArgError, split
9
+ from fcloud.client import PaymentOverdueError
10
+
11
+
12
+ def _strip_precommand_json(rest: list[str], command: list[str]) -> list[str]:
13
+ """Drop a fcloud-level ``--json`` that appears before the command begins.
14
+
15
+ ``command`` is the opaque remote-command suffix from :func:`split` (empty
16
+ for non-passthrough commands). Everything from that boundary onward is the
17
+ user's own argv and is left untouched, so ``fcloud exec -- tool --json``
18
+ still passes ``--json`` to ``tool``.
19
+ """
20
+ boundary = len(rest) - len(command) if command else len(rest)
21
+ head = [t for t in rest[:boundary] if t != "--json"]
22
+ return head + rest[boundary:]
23
+
24
+
25
+ def main():
26
+ argv = sys.argv[1:]
27
+ # A --json *before* the command name is fcloud's global flag; a --json after
28
+ # the command is parsed per-spec below (and belongs to a passthrough
29
+ # command's own argv when it falls after `--`).
30
+ while argv and argv[0] == "--json":
31
+ output._JSON_OUTPUT = True
32
+ argv = argv[1:]
33
+
34
+ if not argv or argv[0] in ("-h", "--help"):
35
+ print_help()
36
+ sys.exit(0 if argv else 1)
37
+
38
+ if argv[0] in ("--version", "-V", "version"):
39
+ from fcloud import __version__
40
+ print(f"fcloud {__version__}")
41
+ sys.exit(0)
42
+
43
+ name = argv[0]
44
+ if name == "help":
45
+ if len(argv) > 1 and argv[1] in COMMANDS:
46
+ print_command_help(argv[1])
47
+ sys.exit(0)
48
+ print_help()
49
+ sys.exit(0)
50
+
51
+ if name not in COMMANDS:
52
+ print(f"Unknown command: {name}")
53
+ print("Run 'fcloud help' for a list of commands.")
54
+ sys.exit(1)
55
+
56
+ if len(argv) > 1 and argv[1] in ("-h", "--help", "help"):
57
+ print_command_help(name)
58
+ sys.exit(0)
59
+
60
+ rest = argv[1:]
61
+ # Validate flags against the command's spec: reject an undeclared leading
62
+ # flag (the typo'd-flag class) and resolve --json without eating the user's own
63
+ # trailing --json. Handlers then parse the (json-stripped) rest themselves.
64
+ spec = SPECS.get(name)
65
+ if spec is not None:
66
+ try:
67
+ parsed = split(spec, rest)
68
+ except ArgError as exc:
69
+ if output._JSON_OUTPUT:
70
+ output._out({"error": str(exc)})
71
+ else:
72
+ print(f"Error: {exc}", file=sys.stderr)
73
+ sys.exit(exc.exit_code)
74
+ if parsed.flags.get("--json"):
75
+ output._JSON_OUTPUT = True
76
+ rest = _strip_precommand_json(rest, parsed.command)
77
+
78
+ handler, _, _ = COMMANDS[name]
79
+ try:
80
+ handler(rest)
81
+ except SystemExit:
82
+ raise
83
+ except PaymentOverdueError as e:
84
+ # A missed payment suspends the whole account: the key is rejected
85
+ # and running sessions are killed. Make the cause unmistakable so the
86
+ # user fixes billing instead of debugging a phantom auth failure.
87
+ if output._JSON_OUTPUT:
88
+ output._out({"error": str(e), "code": "payment_overdue"})
89
+ else:
90
+ print(f"Payment overdue: {e}", file=sys.stderr)
91
+ print(
92
+ "Your account is suspended for a missed payment. Complete the "
93
+ "outstanding payment in your billing dashboard to restore "
94
+ "access.",
95
+ file=sys.stderr,
96
+ )
97
+ sys.exit(3)
98
+ except KeyboardInterrupt:
99
+ # The command's own cleanup (kill remote process, release the
100
+ # session) has already run as the interrupt unwound through it;
101
+ # exit on the conventional SIGINT code without a noisy traceback.
102
+ print("Interrupted.", file=sys.stderr)
103
+ sys.exit(130)
104
+ except Exception as e:
105
+ # Best-effort client telemetry for an otherwise-invisible CLI crash
106
+ # the dispatcher can't see an exception that dies
107
+ # in the client process, so report it before surfacing it locally.
108
+ # Strictly fire-and-forget — report_client_event swallows everything.
109
+ _report_cli_error(e)
110
+ if output._JSON_OUTPUT:
111
+ output._out({"error": str(e)})
112
+ sys.exit(1)
113
+ print(f"Error: {e}", file=sys.stderr)
114
+ sys.exit(1)
115
+
116
+
117
+ def _report_cli_error(exc: Exception) -> None:
118
+ """Best-effort telemetry for an uncaught CLI error, then return.
119
+
120
+ Resolves the dispatcher URL + api key the same way the commands do and
121
+ fires a ``client_cli_error`` event. The error_code is the exception class
122
+ name and the detail is the (truncated) message — never a traceback, and
123
+ never the message of an OS/IO error, since those routinely embed local
124
+ filesystem paths. ``FCLOUD_TELEMETRY=0`` disables this along with every
125
+ other client event (enforced centrally in ``report_client_event``). Any
126
+ failure here is swallowed so telemetry never masks the original error.
127
+ """
128
+ try:
129
+ from fcloud import v2_connect
130
+ from fcloud.config import resolve_api_key, resolve_url
131
+
132
+ detail = "" if isinstance(exc, OSError) else str(exc)
133
+ v2_connect.report_client_event(
134
+ resolve_url(None), resolve_api_key(None),
135
+ event_type="client_cli_error",
136
+ error_code=type(exc).__name__,
137
+ detail=detail,
138
+ )
139
+ except Exception:
140
+ return