envloop-cli 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.
Files changed (32) hide show
  1. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/PKG-INFO +29 -5
  2. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/README.pypi.md +26 -4
  3. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/envloop/agent_runs.py +8 -8
  4. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/envloop/auth_cli.py +4 -1
  5. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/envloop/cli.py +188 -88
  6. envloop_cli-0.3.0/envloop/cli_errors.py +248 -0
  7. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/envloop/cli_output.py +53 -20
  8. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/envloop/client.py +119 -8
  9. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/envloop/http.py +67 -13
  10. envloop_cli-0.3.0/envloop/job_logs.py +16 -0
  11. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/envloop/navigation_resources.py +105 -18
  12. envloop_cli-0.3.0/envloop/profile_secrets.py +27 -0
  13. envloop_cli-0.3.0/envloop/profile_updates.py +49 -0
  14. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/envloop/resource_cli.py +40 -38
  15. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/envloop/resources.py +4 -2
  16. envloop_cli-0.3.0/envloop/sandbox.py +27 -0
  17. envloop_cli-0.3.0/envloop/train.py +68 -0
  18. envloop_cli-0.3.0/envloop/train_log_cli.py +97 -0
  19. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/envloop/training/__init__.py +2 -1
  20. envloop_cli-0.3.0/envloop/training/service_client.py +108 -0
  21. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/pyproject.toml +3 -1
  22. envloop_cli-0.2.1/envloop/train.py +0 -51
  23. envloop_cli-0.2.1/envloop/training/service_client.py +0 -40
  24. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/.gitignore +0 -0
  25. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/envloop/__init__.py +0 -0
  26. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/envloop/auth_login.py +0 -0
  27. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/envloop/auth_workspace.py +0 -0
  28. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/envloop/config.py +0 -0
  29. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/envloop/lifecycle_cli.py +0 -0
  30. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/envloop/training/credentials.py +0 -0
  31. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/envloop/utils/__init__.py +0 -0
  32. {envloop_cli-0.2.1 → envloop_cli-0.3.0}/envloop/utils/logging.py +0 -0
@@ -1,15 +1,17 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: envloop-cli
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: EnvLoop CLI and Python SDK for jobs, training, checkpoints and inference
5
5
  Project-URL: Homepage, https://envloop.ai
6
6
  Project-URL: Documentation, https://docs.envloop.ai
7
+ Project-URL: Repository, https://github.com/EnvLoop/envloop-cli
8
+ Project-URL: Issues, https://github.com/EnvLoop/envloop-cli/issues
7
9
  Project-URL: Agent instructions, https://docs.envloop.ai/skill.md
8
10
  Requires-Python: >=3.11
9
11
  Requires-Dist: tinker==0.30.2
10
12
  Description-Content-Type: text/markdown
11
13
 
12
- # EnvLoop CLI and Python SDK
14
+ # envloop-cli: EnvLoop CLI and Python SDK
13
15
 
14
16
  Install the public client to run agent Jobs, inspect Trials, manage training and
15
17
  checkpoints, and deploy or call inference endpoints. The `el` and `envloop`
@@ -20,7 +22,7 @@ commands are equivalent. The Python import package is `envloop`.
20
22
  Use a Python virtual environment:
21
23
 
22
24
  ```bash
23
- python -m pip install envloop-cli
25
+ python -m pip install --upgrade envloop-cli
24
26
  el --help
25
27
  ```
26
28
 
@@ -57,9 +59,11 @@ your secret manager. Public API access is scoped to the authorized project.
57
59
 
58
60
  ```bash
59
61
  el job list --limit 5
60
- el train list --limit 5
62
+ el job list --type train --limit 5
63
+ el trail list --limit 5
64
+ el trail list --type train --job-id JOB_ID
61
65
  el checkpoint list --limit 5
62
- el inference endpoint list
66
+ el job list --type inference
63
67
  ```
64
68
 
65
69
  Query a returned Job ID with `el job get JOB_ID` and `el job result JOB_ID`.
@@ -73,12 +77,32 @@ from envloop import Client
73
77
 
74
78
  with Client.remote() as client:
75
79
  print(client.jobs.list(limit=5))
80
+ print(client.trials.list(type="train", limit=5))
76
81
  ```
77
82
 
78
83
  Submitting work, training a model or starting inference can allocate billable
79
84
  resources. Follow the task-specific guide and retain the outputs you need before
80
85
  cleaning up temporary resources.
81
86
 
87
+ ## Sandbox jobs
88
+
89
+ With a matching platform and a published `envhub/base` ANS Runner, submit a
90
+ Sandbox Job without a Dataset:
91
+
92
+ ```bash
93
+ el job submit --type sandbox --runner base --command 'python --version'
94
+ el job submit --type sandbox --runner base --image python:3.11 --wait
95
+ el job list --type sandbox
96
+ el job log JOB_ID --follow
97
+ ```
98
+
99
+ The platform resolves `base` to the registered `envhub/base@main` source ref and
100
+ freezes its immutable Revision. The Sandbox image defaults to `python:3.11`;
101
+ `--image` accepts an image name, tag or digest. The execution provider is configured
102
+ by the server. Job logs retain the actual captured output and collection metadata.
103
+ Failures include a code, readable explanation, HTTP status when available, and
104
+ recovery guidance without echoing arbitrary response bodies or credentials.
105
+
82
106
  ## Existing installations
83
107
 
84
108
  This distribution provides both the CLI and the `envloop` import package. Use a
@@ -1,4 +1,4 @@
1
- # EnvLoop CLI and Python SDK
1
+ # envloop-cli: EnvLoop CLI and Python SDK
2
2
 
3
3
  Install the public client to run agent Jobs, inspect Trials, manage training and
4
4
  checkpoints, and deploy or call inference endpoints. The `el` and `envloop`
@@ -9,7 +9,7 @@ commands are equivalent. The Python import package is `envloop`.
9
9
  Use a Python virtual environment:
10
10
 
11
11
  ```bash
12
- python -m pip install envloop-cli
12
+ python -m pip install --upgrade envloop-cli
13
13
  el --help
14
14
  ```
15
15
 
@@ -46,9 +46,11 @@ your secret manager. Public API access is scoped to the authorized project.
46
46
 
47
47
  ```bash
48
48
  el job list --limit 5
49
- el train list --limit 5
49
+ el job list --type train --limit 5
50
+ el trail list --limit 5
51
+ el trail list --type train --job-id JOB_ID
50
52
  el checkpoint list --limit 5
51
- el inference endpoint list
53
+ el job list --type inference
52
54
  ```
53
55
 
54
56
  Query a returned Job ID with `el job get JOB_ID` and `el job result JOB_ID`.
@@ -62,12 +64,32 @@ from envloop import Client
62
64
 
63
65
  with Client.remote() as client:
64
66
  print(client.jobs.list(limit=5))
67
+ print(client.trials.list(type="train", limit=5))
65
68
  ```
66
69
 
67
70
  Submitting work, training a model or starting inference can allocate billable
68
71
  resources. Follow the task-specific guide and retain the outputs you need before
69
72
  cleaning up temporary resources.
70
73
 
74
+ ## Sandbox jobs
75
+
76
+ With a matching platform and a published `envhub/base` ANS Runner, submit a
77
+ Sandbox Job without a Dataset:
78
+
79
+ ```bash
80
+ el job submit --type sandbox --runner base --command 'python --version'
81
+ el job submit --type sandbox --runner base --image python:3.11 --wait
82
+ el job list --type sandbox
83
+ el job log JOB_ID --follow
84
+ ```
85
+
86
+ The platform resolves `base` to the registered `envhub/base@main` source ref and
87
+ freezes its immutable Revision. The Sandbox image defaults to `python:3.11`;
88
+ `--image` accepts an image name, tag or digest. The execution provider is configured
89
+ by the server. Job logs retain the actual captured output and collection metadata.
90
+ Failures include a code, readable explanation, HTTP status when available, and
91
+ recovery guidance without echoing arbitrary response bodies or credentials.
92
+
71
93
  ## Existing installations
72
94
 
73
95
  This distribution provides both the CLI and the `envloop` import package. Use a
@@ -2,7 +2,7 @@
2
2
 
3
3
  The platform stores no run object. Agents tag every Train Job they create with
4
4
  ``user_metadata`` (the RSI loop: ``rsi_run``, ``rsi_round``, ``rsi_checkpoint``...);
5
- this module pages through ``GET /v1/train/jobs`` and derives runs from those tags,
5
+ this module pages through ``GET /v1/job`` and derives runs from those tags,
6
6
  using the same rules as the Console's agent runs page.
7
7
  """
8
8
 
@@ -19,8 +19,8 @@ KEYS = {
19
19
  "checkpoint": ("agent_checkpoint", "rsi_checkpoint"),
20
20
  }
21
21
  RUN_KEYS = KEYS["run"] # first non-empty string wins
22
- ACTIVE_STATUSES = frozenset({"LAUNCHING", "STARTING", "RUNNING", "RECOVERING"})
23
- TERMINAL_STATUSES = frozenset({"SUCCEEDED", "FAILED", "CANCELLED"})
22
+ ACTIVE_STATUSES = frozenset({"LAUNCHING", "STARTING", "RUNNING", "RECOVERING", "queued", "pending", "starting", "running", "recovering", "cancelling"})
23
+ TERMINAL_STATUSES = frozenset({"SUCCEEDED", "FAILED", "CANCELLED", "completed", "failed", "cancelled"})
24
24
  PAGE_LIMIT = 100 # the platform's maximum page size
25
25
 
26
26
 
@@ -70,7 +70,7 @@ def _round(job) -> int | None:
70
70
 
71
71
  def _order(job):
72
72
  # Creation order, as the platform pages jobs (newest first, reversed here).
73
- return (str(job.get("created_at") or ""), str(job.get("train_job_id") or ""))
73
+ return (str(job.get("created_at") or ""), str(job.get("job_id") or job.get("train_job_id") or ""))
74
74
 
75
75
 
76
76
  def _duration_sec(job) -> int | None:
@@ -97,7 +97,7 @@ def _checkpoint(job) -> dict | None:
97
97
  def _job_view(job) -> dict:
98
98
  metadata = _metadata(job)
99
99
  return {
100
- "train_job_id": job.get("train_job_id"),
100
+ "train_job_id": job.get("job_id") or job.get("train_job_id"),
101
101
  "status": job.get("status"),
102
102
  "round": _round(job),
103
103
  "purpose": _field(job, "purpose"),
@@ -121,11 +121,11 @@ def _summary(run: str, jobs: list) -> dict:
121
121
  status = "running"
122
122
  else:
123
123
  status = str(latest.get("status") or "unknown").lower()
124
- succeeded = [job for job in jobs if job.get("status") == "SUCCEEDED"]
124
+ succeeded = [job for job in jobs if job.get("status") in {"SUCCEEDED", "completed"}]
125
125
  latest_saved = next((job for job in reversed(jobs) if _checkpoint(job) is not None), None)
126
126
  latest_checkpoint = _checkpoint(latest_saved) if latest_saved is not None else None
127
127
  if latest_checkpoint is not None:
128
- latest_checkpoint["train_job_id"] = latest_saved.get("train_job_id")
128
+ latest_checkpoint["train_job_id"] = latest_saved.get("job_id") or latest_saved.get("train_job_id")
129
129
  rounds = [number for number in map(_round, jobs) if number is not None]
130
130
  activity = [stamp for job in jobs if isinstance(stamp := job.get("updated_at") or job.get("created_at"), str)]
131
131
  return {
@@ -172,7 +172,7 @@ class AgentRuns:
172
172
  if observed_at is None:
173
173
  observed_at = page.get("observed_at")
174
174
  for job in page.get("items") or []:
175
- job_id = job.get("train_job_id") if isinstance(job, dict) else None
175
+ job_id = job.get("job_id") or job.get("train_job_id") if isinstance(job, dict) else None
176
176
  if not isinstance(job, dict) or job_id in ids:
177
177
  continue
178
178
  if job_id is not None:
@@ -277,9 +277,12 @@ def run_auth(args, parser):
277
277
  config=False, environment=False).list_jobs(limit=1)
278
278
  report["check"] = "access_confirmed" if connection.api_key else "reachable_anonymous"
279
279
  except (ConfigError, RemoteError) as error:
280
+ from .cli_errors import format_error, safe_error_code
281
+
280
282
  report["check"] = {401: "authentication_rejected", 403: "authorization_denied"}.get(
281
- getattr(error, "status_code", None), error.code)
283
+ getattr(error, "status_code", None), safe_error_code(error))
282
284
  _print(report, args.json)
285
+ print(format_error(error, args=args, program=parser.prog), file=sys.stderr)
283
286
  return 1
284
287
  _print(report, getattr(args, "json", False))
285
288
  return 0
@@ -19,8 +19,8 @@ except PackageNotFoundError:
19
19
 
20
20
  SECTIONS = {
21
21
  'OVERVIEW': ('dashboard',),
22
- 'EXPERIMENT': ('job', 'train', 'inference', 'trail'),
23
- 'DATA': ('dataset', 'checkpoint', 'profile'),
22
+ 'EXPERIMENT': ('job', 'trail'),
23
+ 'DATA': ('dataset', 'runner', 'checkpoint', 'profile'),
24
24
  'MANAGE': ('billing', 'limit', 'member', 'setting'),
25
25
  'TOOLS': ('auth', 'help'),
26
26
  }
@@ -111,20 +111,32 @@ def nonnegative_seconds(value):
111
111
  return seconds
112
112
 
113
113
 
114
- def runner_arguments(submit):
115
- def reference(value):
116
- from .client import runner_selector
114
+ def wait_seconds(value):
115
+ try:
116
+ seconds = float(value)
117
+ except ValueError:
118
+ raise argparse.ArgumentTypeError("seconds must be finite and non-negative") from None
119
+ if not math.isfinite(seconds) or seconds < 0:
120
+ raise argparse.ArgumentTypeError("seconds must be finite and non-negative")
121
+ return seconds
122
+
123
+
124
+ def log_page_size(value):
125
+ try:
126
+ count = int(value)
127
+ except ValueError:
128
+ raise argparse.ArgumentTypeError("expected an integer from 1 to 1000") from None
129
+ if not 1 <= count <= 1000:
130
+ raise argparse.ArgumentTypeError("expected an integer from 1 to 1000")
131
+ return count
117
132
 
118
- try:
119
- return runner_selector(value)
120
- except ValueError as exc:
121
- raise argparse.ArgumentTypeError(str(exc)) from None
122
133
 
123
- submit.add_argument("--runner", type=reference, metavar="NAME@REVISION",
124
- help="override the Dataset's pinned runner with an explicit commit, tag or branch")
134
+ def runner_arguments(submit):
135
+ submit.add_argument("--runner", metavar="NAME[@REVISION]",
136
+ help="Agent: override the Dataset runner with NAME@REVISION; Sandbox: registered Runner name (for example base)")
125
137
  submit.add_argument("--job-id", help="caller-selected Job ID")
126
138
  submit.add_argument("--tag", help="run label")
127
- submit.add_argument("--priority", type=int, default=0, help="waiting priority; larger values run first (default: 0)")
139
+ submit.add_argument("--priority", type=int, help="waiting priority; larger values run first (default: 0)")
128
140
  submit.add_argument("--command", help="shell script override executed in the runner directory; preserves multiline text")
129
141
 
130
142
 
@@ -132,7 +144,7 @@ def parser():
132
144
  cli = HelpArgumentParser(
133
145
  prog=Path(sys.argv[0]).name if Path(sys.argv[0]).name in {"envloop", "el"} else "el",
134
146
  usage="%(prog)s [options] [command]", formatter_class=CommandHelpFormatter,
135
- description="Manage jobs, training, agent runs, and trials with EnvLoop.\nConfigure your connection with 'auth config'.",
147
+ description="Manage Agent, Sandbox, Train and Inference Jobs and worker trials with EnvLoop.\nConfigure your connection with 'auth config'.",
136
148
  )
137
149
  cli._optionals.title = "Options"
138
150
  cli.set_defaults(api_url=None, workspace_id=None)
@@ -140,8 +152,11 @@ def parser():
140
152
  groups = cli.add_subparsers(dest="group", title="Commands", prog=cli.prog, required=True)
141
153
  from .auth_cli import add_auth_commands
142
154
 
143
- jobs = groups.add_parser("job", help="Submit and manage Agent jobs").add_subparsers(dest="action", required=True)
144
- job_submit = jobs.add_parser("submit")
155
+ jobs = groups.add_parser("job", help="Submit and manage Agent, Sandbox, Train and Inference Jobs").add_subparsers(dest="action", required=True)
156
+ job_submit = jobs.add_parser("submit", help="Submit an Agent, Sandbox, Train or Inference Job")
157
+ job_submit.add_argument("--type", choices=("agent", "sandbox", "train", "inference"), default="agent", help="Job type (default: agent)")
158
+ job_submit.add_argument("--image", metavar="IMAGE", help="Sandbox Docker image override (server default: python:3.11)")
159
+ job_submit.add_argument("--file", type=Path, help="Train or Inference Job request JSON (spec or Train trials array)")
145
160
  job_submit.add_argument("--source", type=Path, help="two-file directory for a single-task Job")
146
161
  job_submit.add_argument("--task-id", help="select one task within the dataset")
147
162
  job_submit.add_argument(
@@ -154,42 +169,49 @@ def parser():
154
169
  job_submit.add_argument("--split", help="one complete split name; omitted selects all splits")
155
170
  job_submit.add_argument("--revision", help="Git branch/tag/commit; must match the platform's pinned snapshot")
156
171
  runner_arguments(job_submit)
157
- job_submit.add_argument("--attempts", type=int, default=1)
172
+ job_submit.add_argument("--attempts", type=int)
158
173
  job_submit.add_argument("--request-id")
159
174
  job_submit.add_argument("--profile", type=profile_reference, metavar="NAME[@VERSION]",
160
175
  help="named runtime Profile; omitted version requires an explicit default")
161
176
  job_submit.add_argument("--seed", type=int)
162
- job_submit.add_argument("--timeout-sec", type=int)
177
+ job_submit.add_argument("--timeout-sec", type=wait_seconds,
178
+ help="Agent/Sandbox execution timeout (whole seconds); Train wait timeout (default: 600)")
163
179
  job_submit.add_argument("--delete-delay", type=nonnegative_seconds, metavar="SECONDS",
164
180
  help="keep each sandbox (E2B or Fly) after completion for SECONDS (default: 0); cancellation/timeout delete immediately")
165
181
  job_submit.add_argument("--wait", action="store_true")
166
- for action in ("get", "stop", "result"):
167
- jobs.add_parser(action).add_argument("job_id")
182
+ for action in ("get", "stop", "result", "pause", "resume", "wake", "event"):
183
+ command = jobs.add_parser(action)
184
+ command.add_argument("job_id")
185
+ if action == 'event':
186
+ command.add_argument('--json', action='store_true')
187
+ command.set_defaults(_list_view=True)
188
+ from .resource_cli import add_job_resources
189
+ add_job_resources(jobs)
168
190
  listing = jobs.add_parser("list")
169
- listing.add_argument('--type', choices=['agent', 'train'], help='Select Agent or training Jobs')
191
+ listing.add_argument('--type', choices=['agent', 'sandbox', 'train', 'inference'], help='Select Agent, Sandbox, Train or Inference Jobs')
192
+ listing.add_argument('--status', help='Filter by Job status')
170
193
  listing.add_argument("--cursor")
171
194
  listing.add_argument("--json", action="store_true", help="Output the complete response as JSON (default: table)")
172
195
  listing.add_argument("--limit", type=int, default=25)
173
- trains = groups.add_parser("train", help="Submit and inspect independent Train Jobs").add_subparsers(dest="action", required=True)
174
- train_submit = trains.add_parser("submit")
175
- train_submit.add_argument("--file", type=Path, required=True, help="Train Job request JSON")
176
- train_submit.add_argument("--wait", action="store_true")
177
- train_submit.add_argument("--timeout-sec", type=float, default=600)
178
- for action in ("get", "result", "stop"):
179
- trains.add_parser(action).add_argument("train_job_id")
180
- train_list = trains.add_parser("list", help="List visible Train Jobs")
181
- train_list.add_argument("--status")
182
- train_list.add_argument("--cursor")
183
- train_list.add_argument("--limit", type=int, default=25)
184
- train_list.add_argument("--json", action="store_true", help="Output the complete response as JSON (default: table)")
185
- runs = trains.add_parser("run", help="Inspect runs grouped from their Train Jobs").add_subparsers(dest="run_action", required=True)
196
+ train_log = jobs.add_parser("log", help="Read or follow Agent, Sandbox or Train Job logs")
197
+ train_log.add_argument("job_id")
198
+ train_log.add_argument("--source", choices=("all", "system", "runtime"), default="all")
199
+ position = train_log.add_mutually_exclusive_group()
200
+ position.add_argument("--tail", type=log_page_size, help="Start with the last N records (default: 200)")
201
+ position.add_argument("--cursor", help="Resume from a previously returned log cursor")
202
+ train_log.add_argument("--limit", type=log_page_size, default=200, help="Maximum records per page (default: 200)")
203
+ train_log.add_argument("--follow", action="store_true", help="Drain pages and poll until collection ends; Ctrl-C exits without stopping the Job")
204
+ train_log.add_argument("--json", action="store_true", help="Output the complete page as JSON; --follow emits one page per line (NDJSON)")
205
+ runs = jobs.add_parser("run", help="Inspect runs grouped from their Train Jobs").add_subparsers(dest="run_action", required=True)
186
206
  runs_list = runs.add_parser("list", help="List agent runs, most recently updated first")
187
207
  runs_list.add_argument("--json", action="store_true", help="Output the complete response as JSON (default: table)")
188
208
  runs_get = runs.add_parser("get", help="Show a run's rounds, Train Jobs and checkpoints")
189
209
  runs_get.add_argument("run", metavar="RUN", help="run label: user_metadata.rsi_run or agent_run")
190
210
  runs_get.add_argument("--json", action="store_true", help="Output the complete run as JSON (default: tables)")
191
211
  tasks = groups.add_parser("trail", help="Inspect and control existing trials").add_subparsers(dest="action", required=True)
192
- listing = tasks.add_parser("list")
212
+ listing = tasks.add_parser("list", help="List Agent, Sandbox, Train and Inference worker Trials")
213
+ listing.add_argument("--type", choices=("agent", "sandbox", "train", "inference"), help="Filter by Trial type")
214
+ listing.add_argument("--job-id", help="Filter by parent Job ID")
193
215
  listing.add_argument("--cursor")
194
216
  listing.add_argument("--limit", type=int, default=25)
195
217
  snapshot = tasks.add_parser("snapshot")
@@ -212,6 +234,17 @@ def parser():
212
234
  put.add_argument("name")
213
235
  put.add_argument("version")
214
236
  put.add_argument("--file", type=Path, required=True, help="ProfileSpec JSON file")
237
+ secret_input = put.add_mutually_exclusive_group()
238
+ secret_input.add_argument("--prompt-model-api-key", action="store_true", help="Read the model API key from a non-echoing prompt")
239
+ secret_input.add_argument("--model-api-key-stdin", action="store_true", help="Read the model API key from one line of standard input")
240
+ update = profiles.add_parser("update", help="Edit an existing Profile by publishing a new immutable version")
241
+ update.add_argument("name")
242
+ update.add_argument("version", help="New version; existing versions cannot be overwritten")
243
+ update.add_argument("--from-version", help="Source version; omit to use the explicit default")
244
+ update.add_argument("--file", type=Path, required=True, help="JSON patch: merge literal params keys, replace supplied metadata; preserve omitted fields")
245
+ update_secret = update.add_mutually_exclusive_group()
246
+ update_secret.add_argument("--prompt-model-api-key", action="store_true", help="Read an encrypted model connection key from a non-echoing prompt")
247
+ update_secret.add_argument("--model-api-key-stdin", action="store_true", help="Read an encrypted model connection key from one line of standard input")
215
248
  get = profiles.add_parser("get")
216
249
  get.add_argument("name")
217
250
  get.add_argument("--version")
@@ -245,11 +278,11 @@ def parser():
245
278
 
246
279
  def submission_options(args):
247
280
  options = {}
248
- for key in ("runner", "job_id", "tag", "command"):
281
+ for key in ("runner", "job_id", "tag", "command", "image"):
249
282
  value = getattr(args, key, None)
250
283
  if value is not None:
251
284
  options[key] = value
252
- options["priority"] = args.priority
285
+ options["priority"] = args.priority if args.priority is not None else 0
253
286
  if args.profile:
254
287
  if args.runner is not None:
255
288
  options["profile_id"] = args.profile
@@ -322,58 +355,96 @@ def main(argv=None):
322
355
 
323
356
  return run_auth(args, cli)
324
357
  except (EOFError, KeyboardInterrupt):
325
- print("AUTH_INPUT_CANCELLED", file=sys.stderr)
358
+ from .cli_errors import format_error
359
+ from .config import ConfigError
360
+
361
+ print(format_error(ConfigError('AUTH_INPUT_CANCELLED'), args=args, program=cli.prog), file=sys.stderr)
326
362
  return 1
327
363
  except Exception as exc:
328
- print(getattr(exc, "code", None) or type(exc).__name__, file=sys.stderr)
364
+ from .cli_errors import format_error
365
+
366
+ print(format_error(exc, args=args, program=cli.prog), file=sys.stderr)
329
367
  return 1
330
368
  if args.group == "job" and args.action == "submit":
331
- if sum(value is not None for value in (args.dataset, args.dataset_option, args.source)) != 1:
332
- cli.error("provide exactly one positional dataset, --dataset or --source")
333
- args.dataset = args.dataset if args.dataset is not None else args.dataset_option
334
- if args.source is not None and any(getattr(args, key) is not None for key in ("runner", "task_id", "config", "split", "revision")):
335
- cli.error("dataset selection options require a dataset")
369
+ if args.type in {"train", "inference"}:
370
+ if args.file is None:
371
+ cli.error(f"--type {args.type} requires --file REQUEST.json")
372
+ if args.type == 'inference' and (args.wait or args.timeout_sec is not None):
373
+ cli.error('Inference is a continuous service; --wait and --timeout-sec do not apply')
374
+ agent_fields = ("dataset", "dataset_option", "source", "task_id", "config", "split", "revision",
375
+ "runner", "job_id", "tag", "priority", "command", "attempts", "request_id",
376
+ "profile", "seed", "delete_delay", "image")
377
+ if any(getattr(args, key) is not None for key in agent_fields):
378
+ cli.error(f"--type {args.type} accepts its request through --file; Agent submission options cannot be combined")
379
+ elif args.type == 'sandbox':
380
+ from .sandbox import sandbox_runner_selector, validate_image
381
+
382
+ if any(getattr(args, key) is not None for key in (
383
+ 'file', 'dataset', 'dataset_option', 'source', 'task_id', 'config', 'split', 'revision', 'seed')):
384
+ cli.error('--type sandbox accepts --runner without Dataset, Task, Train file or seed options')
385
+ try:
386
+ args.runner = sandbox_runner_selector(args.runner)
387
+ if args.image is not None:
388
+ args.image = validate_image(args.image)
389
+ except ValueError as exc:
390
+ cli.error(str(exc))
391
+ if args.timeout_sec is not None:
392
+ if not args.timeout_sec.is_integer():
393
+ cli.error('Sandbox --timeout-sec must be whole seconds')
394
+ args.timeout_sec = int(args.timeout_sec)
395
+ else:
396
+ if args.file is not None:
397
+ cli.error("--file requires --type train or inference")
398
+ if args.image is not None:
399
+ cli.error('--image requires --type sandbox')
400
+ if args.runner is not None:
401
+ from .client import runner_selector
402
+
403
+ try:
404
+ args.runner = runner_selector(args.runner)
405
+ except ValueError as exc:
406
+ cli.error(str(exc))
407
+ if sum(value is not None for value in (args.dataset, args.dataset_option, args.source)) != 1:
408
+ cli.error("provide exactly one positional dataset, --dataset or --source")
409
+ args.dataset = args.dataset if args.dataset is not None else args.dataset_option
410
+ if args.source is not None and any(getattr(args, key) is not None for key in ("runner", "task_id", "config", "split", "revision")):
411
+ cli.error("dataset selection options require a dataset")
412
+ if args.timeout_sec is not None:
413
+ if not args.timeout_sec.is_integer():
414
+ cli.error("Agent --timeout-sec must be whole seconds")
415
+ args.timeout_sec = int(args.timeout_sec)
336
416
  args._command_path = args._help_parser.prog.split()[1:]
337
417
  if args.group == 'setting' and args.action == 'update' and not any(getattr(args, key) is not None for key in ('name', 'slug', 'region')):
338
418
  cli.error('provide at least one of --name, --slug or --region')
339
419
  try:
340
420
  resolved = resolve_cli_connection(args.api_url, args.workspace_id)
341
421
  args.api_url = resolved.api_url
342
- if args.group == "train" and args.action != "run":
343
- if args.action == "submit" and args.wait and (not math.isfinite(args.timeout_sec) or args.timeout_sec < 0):
344
- print("INVALID_TRAIN_WAIT_TIMEOUT", file=sys.stderr)
345
- return 1
346
- if not args.api_url:
347
- print("TRAIN_API_URL_REQUIRED", file=sys.stderr)
348
- return 1
349
- from .http import HttpService
350
- from .train import TrainJobs
351
-
352
- train = TrainJobs(HttpService(args.api_url, api_key=resolved.api_key, workspace_id=resolved.workspace_id,
353
- project_id=resolved.project_id, config=False, environment=False))
354
- if args.action == "submit":
355
- result = train.submit(json.loads(args.file.read_text(encoding="utf-8")))
356
- if args.wait:
357
- result = train.wait(result["train_job_id"], timeout=args.timeout_sec)
358
- elif args.action == "list":
359
- result = train.list(status=args.status, cursor=args.cursor, limit=args.limit)
360
- else:
361
- result = getattr(train, 'status' if args.action == 'get' else args.action)(args.train_job_id)
362
- if args.action == "list" and not args.json:
363
- from .cli_output import list_output
364
-
365
- print(list_output(result, args, cli.prog))
366
- else:
367
- print(json.dumps(result, ensure_ascii=False))
368
- return 1 if result.get("status") in {"FAILED", "CANCELLED"} else 0
369
422
  connection = Client.remote(args.api_url, api_key=resolved.api_key, workspace_id=resolved.workspace_id,
370
423
  project_id=resolved.project_id, config=False)
371
424
  with connection as client:
372
425
  if args.group == "profile":
373
- if args.action == "create":
374
- result = client.profiles.import_spec(
375
- args.file.read_bytes(), name=args.name, version=args.version
376
- )
426
+ if args.action in {"create", "update"}:
427
+ secrets = None
428
+ if args.prompt_model_api_key:
429
+ import getpass
430
+ import warnings
431
+
432
+ # Fail closed if the terminal cannot disable echo.
433
+ with warnings.catch_warnings():
434
+ warnings.simplefilter("error", getpass.GetPassWarning)
435
+ key = getpass.getpass("Model API key: ")
436
+ secrets = {"model_api_key": key}
437
+ elif args.model_api_key_stdin:
438
+ secrets = {"model_api_key": sys.stdin.readline(4098).rstrip("\r\n")}
439
+ if args.action == "update":
440
+ from .profile_updates import read_profile_patch
441
+
442
+ result = client.profiles.update(args.name, args.version,
443
+ patch=read_profile_patch(args.file.read_bytes()), from_version=args.from_version, secrets=secrets)
444
+ else:
445
+ result = client.profiles.import_spec(
446
+ args.file.read_bytes(), name=args.name, version=args.version, secrets=secrets
447
+ )
377
448
  elif args.action == "get":
378
449
  result = client.profiles.get(args.name, args.version)
379
450
  elif args.action == "list":
@@ -396,20 +467,37 @@ def main(argv=None):
396
467
  return 0
397
468
  elif args.group == "job":
398
469
  if args.action == "submit":
399
- result = client.jobs.submit(args.dataset, source=args.source, task_id=args.task_id, attempts=args.attempts, request_id=args.request_id,
400
- config=args.config, split=args.split,
401
- revision=args.revision, **submission_options(args))
470
+ if args.type == 'inference':
471
+ result = client.jobs.submit_inference(json.loads(args.file.read_text(encoding='utf-8')))
472
+ elif args.type == "train":
473
+ result = client.training_jobs.submit(json.loads(args.file.read_text(encoding="utf-8")))
474
+ else:
475
+ result = client.jobs.submit(args.dataset, type=args.type, source=args.source, task_id=args.task_id,
476
+ attempts=args.attempts if args.attempts is not None else 1,
477
+ request_id=args.request_id, config=args.config, split=args.split,
478
+ revision=args.revision, **submission_options(args))
402
479
  if args.wait:
403
- result = client.jobs.wait(result["job_id"])
480
+ wait_options = {"timeout": args.timeout_sec} if args.type == "train" and args.timeout_sec is not None else {}
481
+ result = client.jobs.wait(result["job_id"], **wait_options)
482
+ elif args.action == 'event':
483
+ result = client.jobs.events(args.job_id)
484
+ elif args.action in {'serving-profile', 'adapter'}:
485
+ from .resource_cli import run_job_resource
486
+ result = run_job_resource(client, args)
487
+ elif args.action == "log":
488
+ from .train_log_cli import read_logs
489
+
490
+ return read_logs(client.jobs, args)
491
+ elif args.action == "run":
492
+ result = client.agent_runs.list() if args.run_action == "list" else client.agent_runs.get(args.run)
404
493
  elif args.action == "list":
405
494
  options = {'cursor': args.cursor, 'limit': args.limit}
406
- if args.type is not None:
407
- options['type'] = args.type
495
+ for field in ('type', 'status'):
496
+ if getattr(args, field) is not None:
497
+ options[field] = getattr(args, field)
408
498
  result = client.jobs.list(**options)
409
499
  else:
410
500
  result = getattr(client.jobs, 'status' if args.action == 'get' else args.action)(args.job_id)
411
- elif args.group == "train":
412
- result = client.agent_runs.list() if args.run_action == "list" else client.agent_runs.get(args.run)
413
501
  elif args.group == "trail":
414
502
  if args.action == "artifact":
415
503
  if args.artifact_action == "download":
@@ -417,19 +505,30 @@ def main(argv=None):
417
505
  else:
418
506
  result = client.trials.artifacts(args.trial_id, cursor=args.cursor, limit=args.limit)
419
507
  elif args.action == "list":
420
- result = client.trials.list(cursor=args.cursor, limit=args.limit)
508
+ options = {'cursor': args.cursor, 'limit': args.limit}
509
+ for field in ('type', 'job_id'):
510
+ if getattr(args, field) is not None:
511
+ options[field] = getattr(args, field)
512
+ result = client.trials.list(**options)
421
513
  else:
422
514
  result = getattr(client.trials, 'status' if args.action == 'get' else args.action)(args.trial_id)
423
515
  else:
424
516
  from .resource_cli import execute
425
517
 
426
518
  result = execute(args, client)
427
- if args.group == 'train' and args.action == 'run' and args.run_action == 'get' and not args.json:
519
+ if isinstance(result, dict) and 'operation_id' in result:
520
+ from .cli_errors import operation_output
521
+ from .resources import CleanupError
522
+
523
+ if result.get('status') in {'FAILED', 'CANCELLED', 'RETRYABLE_FAILED', 'BLOCKED'}:
524
+ raise CleanupError(result)
525
+ result = operation_output(result)
526
+ if args.group == 'job' and args.action == 'run' and args.run_action == 'get' and not args.json:
428
527
  from .cli_output import run_output
429
528
 
430
529
  print(run_output(result))
431
530
  elif (args.action == 'list' or getattr(args, '_list_view', False)
432
- or (args.group == 'train' and args.action == 'run' and args.run_action == 'list')) and not args.json:
531
+ or (args.group == 'job' and args.action == 'run' and args.run_action == 'list')) and not args.json:
433
532
  from .cli_output import list_output
434
533
 
435
534
  print(list_output(result, args, cli.prog))
@@ -437,14 +536,15 @@ def main(argv=None):
437
536
  print(json.dumps(result, ensure_ascii=False))
438
537
  return 1 if isinstance(result, dict) and (result.get('status') in {'failed', 'FAILED', 'CANCELLED', 'RETRYABLE_FAILED', 'BLOCKED'} or result.get('skipped')) else 0
439
538
  except Exception as exc:
539
+ from .cli_errors import format_error, operation_output
440
540
  from .resources import CleanupError
441
541
 
442
542
  if isinstance(exc, CleanupError):
443
- print(json.dumps(exc.operation, ensure_ascii=False))
444
- print('CLEANUP_INCOMPLETE', file=sys.stderr)
543
+ print(json.dumps(operation_output(exc.operation), ensure_ascii=False))
544
+ print(format_error(exc, args=args, program=cli.prog), file=sys.stderr)
445
545
  return 1
446
546
  # Do not emit arbitrary exception messages, file content or credentials.
447
- print(getattr(exc, "code", None) or type(exc).__name__, file=sys.stderr)
547
+ print(format_error(exc, args=args, program=cli.prog), file=sys.stderr)
448
548
  return 1
449
549
 
450
550