envloop-cli 1.29.0__tar.gz → 1.31.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 (66) hide show
  1. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/PKG-INFO +3 -3
  2. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/README.pypi.md +2 -2
  3. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/auth_cli.py +3 -0
  4. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/auth_login.py +4 -1
  5. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/cli_errors.py +10 -3
  6. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/http.py +10 -4
  7. envloop_cli-1.31.0/envloop/lifecycle_cli.py +118 -0
  8. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/resource_cli.py +20 -4
  9. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/data.py +9 -2
  10. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/data_cli.py +6 -2
  11. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/resource_cli.py +12 -7
  12. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/cli.py +18 -4
  13. envloop_cli-1.31.0/packages/experiment/envloop_experiment/inference.py +79 -0
  14. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/job_spec_cli.py +5 -1
  15. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/resource_cli.py +5 -1
  16. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/train_log_cli.py +4 -1
  17. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/train_metrics_cli.py +4 -3
  18. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/train_progress.py +17 -1
  19. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/pyproject.toml +2 -2
  20. envloop_cli-1.29.0/envloop/lifecycle_cli.py +0 -55
  21. envloop_cli-1.29.0/packages/experiment/envloop_experiment/inference.py +0 -36
  22. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/.gitignore +0 -0
  23. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/__init__.py +0 -0
  24. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/_optional.py +0 -0
  25. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/auth_workspace.py +0 -0
  26. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/cli.py +0 -0
  27. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/cli_output.py +0 -0
  28. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/client.py +0 -0
  29. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/config.py +0 -0
  30. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/downloads.py +0 -0
  31. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/navigation_resources.py +0 -0
  32. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/profile_secrets.py +0 -0
  33. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/resources.py +0 -0
  34. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/terminal.py +0 -0
  35. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/utils/__init__.py +0 -0
  36. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/utils/logging.py +0 -0
  37. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/__init__.py +0 -0
  38. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/cli.py +0 -0
  39. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/cli_output.py +0 -0
  40. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/client.py +0 -0
  41. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/data_publications.py +0 -0
  42. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/data_upload.py +0 -0
  43. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/http.py +0 -0
  44. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/navigation_resources.py +0 -0
  45. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/profile_updates.py +0 -0
  46. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/recipes.py +0 -0
  47. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/runner_packages.py +0 -0
  48. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/__init__.py +0 -0
  49. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/cli_output.py +0 -0
  50. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/client.py +0 -0
  51. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/decision.py +0 -0
  52. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/http.py +0 -0
  53. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/job_cost.py +0 -0
  54. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/job_logs.py +0 -0
  55. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/navigation_resources.py +0 -0
  56. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/resources.py +0 -0
  57. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/runtime_params.py +0 -0
  58. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/sandbox.py +0 -0
  59. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/train.py +0 -0
  60. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/train_runner_cli.py +0 -0
  61. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/training/__init__.py +0 -0
  62. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/rsi-loop/envloop_rsi_loop/__init__.py +0 -0
  63. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/rsi-loop/envloop_rsi_loop/agent_runs.py +0 -0
  64. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/rsi-loop/envloop_rsi_loop/cli.py +0 -0
  65. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/rsi-loop/envloop_rsi_loop/cli_output.py +0 -0
  66. {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/rsi-loop/envloop_rsi_loop/rsi_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: envloop-cli
3
- Version: 1.29.0
3
+ Version: 1.31.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
@@ -249,7 +249,7 @@ The platform starts the published Revision’s `main.sh` / `main.py` using the R
249
249
  CLI output uses compact timestamps and highlights statuses and log levels by default on terminals.
250
250
  Use `el --no-color job list` or append `--no-color` to a command to disable highlighting, including errors.
251
251
  `NO_COLOR=1`, `TERM=dumb`, pipes, and redirected output also disable colors. JSON preserves original values.
252
- List times use the local timezone; Job logs retain the recorded timezone, with full precision and offsets available through `--json`.
252
+ List and table times are UTC with a `UTC` label; Job logs retain the recorded timezone and mark lines collected only after the Job ended with `(backfilled)`. Full precision, offsets and the `backfilled` field are available through `--json`.
253
253
  `el job run live RUN --follow --json` emits complete response frames as NDJSON; Ctrl-C stops reading without stopping the Run.
254
254
 
255
255
  ## Job-bound decision plugins
@@ -283,7 +283,7 @@ download a complete registered ANS object directory through EnvHub. Datasets req
283
283
  authorization the SDK uses GET URLs valid for up to five minutes, verifies file size/SHA-256,
284
284
  and installs the directory atomically. Matching platform support is required.
285
285
 
286
- A compatible server and an explicitly granted project Key with `envloop:data:read` are required; writes additionally need `envloop:data:write`. Default and older Keys do not gain these permissions. The current login selects the base URL, workspace and project; there is no `--env` flag. After configuring the Key using `el auth config`:
286
+ A compatible server is required. Data uses the permission of the resource it touches: read (for example `datasets.read`) to query and download, write to publish and upload, delete to retire; a key from `el auth login` follows your project role. The current login selects the base URL, workspace and project; there is no `--env` flag. After configuring the Key using `el auth config`:
287
287
 
288
288
  ```bash
289
289
  el data list --type dataset --json
@@ -232,7 +232,7 @@ The platform starts the published Revision’s `main.sh` / `main.py` using the R
232
232
  CLI output uses compact timestamps and highlights statuses and log levels by default on terminals.
233
233
  Use `el --no-color job list` or append `--no-color` to a command to disable highlighting, including errors.
234
234
  `NO_COLOR=1`, `TERM=dumb`, pipes, and redirected output also disable colors. JSON preserves original values.
235
- List times use the local timezone; Job logs retain the recorded timezone, with full precision and offsets available through `--json`.
235
+ List and table times are UTC with a `UTC` label; Job logs retain the recorded timezone and mark lines collected only after the Job ended with `(backfilled)`. Full precision, offsets and the `backfilled` field are available through `--json`.
236
236
  `el job run live RUN --follow --json` emits complete response frames as NDJSON; Ctrl-C stops reading without stopping the Run.
237
237
 
238
238
  ## Job-bound decision plugins
@@ -266,7 +266,7 @@ download a complete registered ANS object directory through EnvHub. Datasets req
266
266
  authorization the SDK uses GET URLs valid for up to five minutes, verifies file size/SHA-256,
267
267
  and installs the directory atomically. Matching platform support is required.
268
268
 
269
- A compatible server and an explicitly granted project Key with `envloop:data:read` are required; writes additionally need `envloop:data:write`. Default and older Keys do not gain these permissions. The current login selects the base URL, workspace and project; there is no `--env` flag. After configuring the Key using `el auth config`:
269
+ A compatible server is required. Data uses the permission of the resource it touches: read (for example `datasets.read`) to query and download, write to publish and upload, delete to retire; a key from `el auth login` follows your project role. The current login selects the base URL, workspace and project; there is no `--env` flag. After configuring the Key using `el auth config`:
270
270
 
271
271
  ```bash
272
272
  el data list --type dataset --json
@@ -20,6 +20,9 @@ def add_auth_commands(groups: "argparse._SubParsersAction[argparse.ArgumentParse
20
20
  login.add_argument("--no-browser", action="store_true", help="Print the login link without opening a browser")
21
21
  login.add_argument("--timeout-seconds", type=int, choices=range(1, 601), metavar="SECONDS", default=300,
22
22
  help="Wait up to 1–600 seconds (default: 300)")
23
+ login.add_argument("--read-only", action="store_true",
24
+ help="Issue a read-only key, for example for CI that only reads results. "
25
+ "Without it the key follows your project role")
23
26
  configure = commands.add_parser("config", help="Save Base URL and API key for CLI and SDK")
24
27
  configure.add_argument("--no-check", action="store_true", help="Save without API verification (offline; key remains unverified)")
25
28
  actions = configure.add_subparsers(dest="config_action")
@@ -76,7 +76,8 @@ def login(args: argparse.Namespace, store: Any) -> int:
76
76
  def digest(value: str) -> str:
77
77
  return hashlib.sha256(value.encode()).hexdigest()
78
78
  response = _request(url, 'start', {'key_hash': digest(key), 'poll_hash': digest(poll_token),
79
- 'code_hash': digest(code)}, min(5, args.timeout_seconds))
79
+ 'code_hash': digest(code), **({'read_only': True} if args.read_only else {})},
80
+ min(5, args.timeout_seconds))
80
81
  identifier, uri = _verification_url(url, response)
81
82
  interval, expires = response.get('interval'), response.get('expires_in')
82
83
  if (isinstance(interval, bool) or not isinstance(interval, (int, float)) or not 0 < interval <= 10 or
@@ -88,6 +89,8 @@ def login(args: argparse.Namespace, store: Any) -> int:
88
89
  try:
89
90
  print(f'Base URL: {url}\nConfirmation code: {code}\nOpen: {uri}', flush=True)
90
91
  print('Sign in with Clerk, enter this code, and authorize your workspace/project.', flush=True)
92
+ print('The key will be read only.' if args.read_only else
93
+ 'The key follows your project role. The page lists its permissions before you authorize.', flush=True)
91
94
  if not args.no_browser:
92
95
  try:
93
96
  opened = webbrowser.open(uri, new=1)
@@ -27,10 +27,10 @@ def _register(codes: str, message: str) -> None:
27
27
  _MESSAGES[code] = message
28
28
 
29
29
 
30
- _register("DATA_SCOPE_REQUIRED", "Configure a project key with explicit Data read permission, plus Data write for publication and upload management.")
30
+ _register("DATA_ACCESS_REQUIRED", "This key lacks the permission named above on the Dataset, Runner, Profile, Checkpoint or Trial artifact. Run el auth login to issue a new key that follows your project role: every role can read, and Developer or Admin can also write and delete (create, update, delete, upload, runner register). A key from el auth login --read-only cannot write; log in again without --read-only. Or give a key Read or Edit on that page in the Console under CONFIGURE > API Keys. Existing keys never gain permissions.")
31
31
  _register("DATA_NATIVE_INCOMPLETE DATA_NATIVE_UNAVAILABLE", "Native catalog registration is incomplete. Retry the same input after the platform is ready.")
32
32
  _register("DATA_DEPENDENCIES_ACTIVE DATA_DEPENDENCIES_UNKNOWN DATA_DEPENDENCIES_CHANGED DATA_DELETE_CONFLICT", "Retirement requires a complete, current dependency check. Resolve active or unknown dependencies, then retry the same resource.")
33
- _register("DATA_PUBLICATION_INVALID", "Check the immutable ANS URI, native manifest and exact file sizes and SHA-256 values.")
33
+ _register("DATA_PUBLICATION_INVALID", "Check the immutable ANS URI, native manifest and exact file sizes and SHA-256 values. A record Dataset source is https://github.com/OWNER/REPO or https://huggingface.co/datasets/ORG/NAME with the full commit, not a branch or tag.")
34
34
  _register("DATA_PIN_CONFLICT DATA_REVISION_REQUIRED", "Publish a new immutable revision; existing and revoked references cannot be overwritten.")
35
35
  _register("DATA_QUOTA_EXCEEDED DATA_QUOTA_UNCONFIGURED", "Ask a project administrator to configure publication storage quota. Revoking publications does not free retained content.")
36
36
  _register("DATA_UPLOAD_CONFLICT DATA_UPLOAD_INCOMPLETE DATA_UPLOAD_RATE_LIMITED", "Inspect the upload with data upload get; resume matching parts or explicitly abort it.")
@@ -305,6 +305,9 @@ def format_error(error: BaseException) -> str:
305
305
  code = remote.code
306
306
  local = _http_message(status) if code == "HTTP_ERROR" or code not in _MESSAGES else _MESSAGES[code]
307
307
  extra: dict[str, Any] = {"hint": remote.hint, "details": remote.details, "request_id": remote.request_id} if remote else {}
308
+ if remote and remote.message and remote.details:
309
+ # A detail that repeats the message (one refused field) only adds where it is: show the field alone.
310
+ extra["details"] = [line.removesuffix(": " + remote.message) for line in remote.details if line != remote.message]
308
311
  if remote and remote.message:
309
312
  # Local text only adds guidance for codes the CLI documents.
310
313
  # A gateway timeout always adds that the request may have been processed.
@@ -329,7 +332,11 @@ def upload_recovery(error: BaseException) -> str:
329
332
  if isinstance(upload_id, str) and re.fullmatch(r'upload_[a-f0-9]{32}', upload_id):
330
333
  lines.extend([f' Upload retained: {upload_id}', f' Inspect with: el data upload get {upload_id}'])
331
334
  generated = getattr(error, 'generated_idempotency_key', None)
332
- if isinstance(generated, str) and re.fullmatch(r'[a-f0-9]{32}', generated):
335
+ # A definite refusal (permission, validation, not found) fails the same way again; offer the key only
336
+ # when the outcome is unknown or retryable.
337
+ status = getattr(error, 'status_code', None)
338
+ retryable = not isinstance(status, int) or status >= 500 or status in {408, 429}
339
+ if retryable and isinstance(generated, str) and re.fullmatch(r'[a-f0-9]{32}', generated):
333
340
  lines.append(f' Retry the same input with --idempotency-key {generated}')
334
341
  return '\n'.join(lines)
335
342
 
@@ -127,6 +127,9 @@ def _text(value: Any, limit: int, secrets: tuple[str | None, ...] = ()) -> str |
127
127
  return (value[:limit - 1] + "\u2026" if len(value) > limit else value) or None
128
128
 
129
129
 
130
+ _DETAIL_TEXT = frozenset({"field", "loc", "name", "message", "msg", "error"})
131
+
132
+
130
133
  def _detail_lines(value: Any, secrets: tuple[str | None, ...]) -> list[str]:
131
134
  """Field-level validation detail from `fields`/`details`, as at most 10 short lines."""
132
135
  if isinstance(value, dict):
@@ -137,10 +140,13 @@ def _detail_lines(value: Any, secrets: tuple[str | None, ...]) -> list[str]:
137
140
  field = item.get("field") or item.get("loc") or item.get("name")
138
141
  field = ".".join(map(str, field)) if isinstance(field, list) else field
139
142
  message = item.get("message") or item.get("msg") or item.get("error")
140
- # A flat record such as {task, size_bytes, limit_bytes} reads as key=value pairs.
141
- item = f"{field}: {message}" if field and message else message or field or ", ".join(
142
- f"{key}={value}" for key, value in list(item.items())[:16]
143
- if isinstance(key, str) and isinstance(value, (str, int, float)) and not isinstance(value, bool))
143
+ # A flat record such as {task, size_bytes, limit_bytes} reads as key=value pairs; next to a field the
144
+ # same pairs locate it, e.g. `records (record_index=2): ...`.
145
+ pairs = ", ".join(f"{key}={value}" for key, value in list(item.items())[:16]
146
+ if isinstance(key, str) and key not in _DETAIL_TEXT
147
+ and isinstance(value, (str, int, float)) and not isinstance(value, bool))
148
+ located = f"{field} ({pairs})" if field and pairs else field
149
+ item = f"{located}: {message}" if field and message else message or located or pairs
144
150
  if line := _text(item, 200, secrets):
145
151
  lines.append(line)
146
152
  return lines
@@ -0,0 +1,118 @@
1
+ """Project lifecycle commands nested under Console Settings."""
2
+ import argparse
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ TIMEOUT_HELP = ('Seconds to wait (default 600); on timeout the command exits 1 with WAIT_TIMEOUT and prints the '
9
+ 'Operation, and cleanup continues on the server')
10
+ OPERATION_READ = ('Reading an Operation needs the Key permission Resource cleanup, under MANAGE (resources.read); without '
11
+ 'it the read returns ACCESS_DENIED (HTTP 403).')
12
+ OPERATION = ('An Operation is the persisted record of one cleanup request. el setting cleanup, el setting '
13
+ 'resource-scope cleanup, el job adapter delete and el checkpoint delete return one (operation_id), and '
14
+ 'a stopped Inference Job names its own in inference.cleanup_operation_id. Status is PENDING or RUNNING '
15
+ 'while cleanup works, RETRYING after a failure the server retries by itself, and SUCCEEDED when every '
16
+ 'resource is released; resources_deleted, retained and errors give the outcome per resource. '
17
+ 'FAILED, BLOCKED, RETRYABLE_FAILED, CANCELLED or SUCCEEDED with errors mean cleanup is incomplete: '
18
+ 'fix the named cause and run el setting operation retry OPERATION_ID. ' + OPERATION_READ)
19
+ DENIED_HINT = ('This Key cannot read cleanup Operations: it needs the Resource cleanup permission, under MANAGE '
20
+ '(resources.read). Without it, check the resource itself: el job get JOB_ID shows cleanup_confirmed, '
21
+ 'and el job adapter get ID returns 404 once the Adapter is deleted.')
22
+
23
+
24
+ def wait_options(parser: argparse.ArgumentParser):
25
+ parser.add_argument('--wait', action='store_true',
26
+ help='Then wait for the cleanup Operation to finish (see el setting operation wait --help). If '
27
+ 'this Key cannot read Operations, print the Operation and exit 0, since the request '
28
+ 'itself succeeded')
29
+ parser.add_argument('--timeout-seconds', type=float, default=600, help=TIMEOUT_HELP)
30
+
31
+
32
+ def wait_after_request(client: Any, result: Any, *, timeout: float, check: str) -> Any:
33
+ """``--wait`` after an accepted cleanup request (delete, cleanup, scope cleanup).
34
+
35
+ The request already succeeded. A Key may delete without being allowed to read Operations; a
36
+ denied read then reports the Operation and how to confirm the outcome instead of failing.
37
+ """
38
+ if not isinstance(result, dict) or result.get('status') == 'SUCCEEDED' and not result.get('errors'):
39
+ return result
40
+ from .http import RemoteError
41
+
42
+ try:
43
+ return client.operations.wait(result['operation_id'], timeout=timeout)
44
+ except RemoteError as error:
45
+ if error.status_code != 403:
46
+ raise
47
+ print(f"Cleanup accepted as Operation {result['operation_id']}, but this Key cannot read cleanup Operations "
48
+ f"(resources.read), so --wait stopped. {check}", file=sys.stderr)
49
+ return result
50
+
51
+
52
+ def add_management_commands(settings: "argparse._SubParsersAction[argparse.ArgumentParser]"):
53
+ operations = settings.add_parser('operation', help='Inspect, wait for or retry a cleanup Operation',
54
+ description=OPERATION).add_subparsers(dest='operation_action', required=True)
55
+ get = operations.add_parser('get', help='Show a cleanup Operation once', description='Show the current state of a '
56
+ 'cleanup Operation without waiting. ' + OPERATION)
57
+ retry = operations.add_parser('retry', help='Retry an incomplete cleanup Operation', description=(
58
+ 'Start the remaining work of an incomplete cleanup Operation again; needs Resource cleanup edit '
59
+ 'permission, under MANAGE (resources.cleanup). ' + OPERATION))
60
+ wait = operations.add_parser('wait', help='Poll a cleanup Operation until it finishes', description=(
61
+ 'Read the Operation every 2 seconds until it finishes, then print it. Exit 0 when it is SUCCEEDED without '
62
+ 'errors, exit 1 with CLEANUP_INCOMPLETE when cleanup is incomplete, and exit 1 with WAIT_TIMEOUT after '
63
+ '--timeout-seconds (default 600) while cleanup continues on the server; run wait again later. ' + OPERATION))
64
+ for command in (get, retry, wait):
65
+ command.add_argument('identifier', metavar='OPERATION_ID', help='Operation ID (op_...)')
66
+ wait.add_argument('--timeout-seconds', type=float, default=600, help=TIMEOUT_HELP)
67
+ cleanup = settings.add_parser('cleanup', help='Clean an explicit set of Project resource IDs')
68
+ cleanup.add_argument('--file', type=Path, required=True,
69
+ help='JSON array of {"kind": KIND, "id": ID} objects; KIND is job (a Train Job), endpoint, adapter '
70
+ 'or checkpoint, for example [{"kind": "job", "id": "job_..."}]')
71
+ cleanup.add_argument('--request-id', required=True)
72
+ wait_options(cleanup)
73
+ scopes = settings.add_parser('resource-scope', help='Manage temporary resources with server TTL').add_subparsers(dest='scope_action', required=True)
74
+ create = scopes.add_parser('create')
75
+ create.add_argument('--ttl-seconds', type=int, default=1800)
76
+ create.add_argument('--request-id', required=True)
77
+ for action in ('get', 'cleanup', 'retain'):
78
+ command = scopes.add_parser(action)
79
+ command.add_argument('identifier', metavar='RESOURCE_SCOPE_ID')
80
+ if action == 'cleanup':
81
+ wait_options(command)
82
+ if action == 'retain':
83
+ command.add_argument('--file', type=Path, required=True, help='JSON array of resources to retain')
84
+
85
+
86
+ def run_management(client: Any, args: argparse.Namespace):
87
+ from .resources import _id
88
+
89
+ if args.action == 'operation':
90
+ from .http import RemoteError
91
+
92
+ try:
93
+ return (client.operations.wait(args.identifier, timeout=args.timeout_seconds) if args.operation_action == 'wait'
94
+ else getattr(client.operations, args.operation_action)(args.identifier))
95
+ except RemoteError as error:
96
+ if error.status_code == 403 and args.operation_action != 'retry' and not error.hint:
97
+ error.hint = DENIED_HINT
98
+ raise
99
+ if args.action == 'resource-scope':
100
+ if args.scope_action == 'create':
101
+ client.temporary_resources(ttl_seconds=args.ttl_seconds)
102
+ base = client.operations.project_path + '/temporary-resource-scopes'
103
+ if args.scope_action == 'create':
104
+ result = client._transport.request('POST', base, {'ttl_seconds': args.ttl_seconds, 'request_id': args.request_id})
105
+ else:
106
+ path = base + '/' + _id(args.identifier)
107
+ result = (client._transport.request('POST', path + '/retain', {'resources': json.loads(args.file.read_text())})
108
+ if args.scope_action == 'retain' else client._transport.request('DELETE' if args.scope_action == 'cleanup' else 'GET', path))
109
+ if args.scope_action != 'cleanup':
110
+ return result
111
+ check = f'Check with: el setting resource-scope get {args.identifier} (status CLEANED).'
112
+ else:
113
+ result = client.operations.cleanup(json.loads(args.file.read_text()), request_id=args.request_id)
114
+ check = ('Check each resource: el job get JOB_ID (training.cleanup_confirmed true), el job adapter get ID '
115
+ '(404 = deleted), el checkpoint list (deleted Checkpoints are gone).')
116
+ if not getattr(args, 'wait', False):
117
+ return result
118
+ return wait_after_request(client, result, timeout=args.timeout_seconds, check=check)
@@ -1,10 +1,22 @@
1
1
  import argparse
2
2
  import sys
3
3
  from collections.abc import Sequence
4
+ from datetime import datetime, timedelta, timezone
4
5
  from typing import Any
5
6
 
6
7
  from .navigation_resources import billing_timestamp
7
8
 
9
+ USAGE_HELP = ('List recorded usage in a time window; without --start and --end, the last 7 days ending now (UTC). '
10
+ 'Example: el billing usage list --start 2026-11-01 --end 2026-11-05 --group-by day')
11
+ USAGE_DAYS = 7
12
+
13
+
14
+ def usage_window(start: str | None, end: str | None) -> tuple[str, str]:
15
+ """An omitted end is now (UTC, whole seconds); an omitted start is USAGE_DAYS before the end."""
16
+ end_at = datetime.fromisoformat(end) if end else datetime.now(timezone.utc).replace(microsecond=0)
17
+ start_at = datetime.fromisoformat(start) if start else end_at - timedelta(days=USAGE_DAYS)
18
+ return start_at.isoformat(), end_at.isoformat()
19
+
8
20
 
9
21
  def listing(commands: "argparse._SubParsersAction[argparse.ArgumentParser]", *, name: str = 'list', paged: bool = True, help: str = 'List visible resources') -> argparse.ArgumentParser:
10
22
  parser = commands.add_parser(name, help=help)
@@ -23,10 +35,13 @@ def add_resource_commands(groups: "argparse._SubParsersAction[argparse.ArgumentP
23
35
  billing = groups.add_parser('billing', help='Inspect Project billing and recorded usage').add_subparsers(dest='resource', required=True)
24
36
  for resource in ('account', 'usage', 'ledger', 'price'):
25
37
  commands = billing.add_parser(resource, help=f'Inspect billing {resource}').add_subparsers(dest='action', required=True)
26
- command = commands.add_parser('get', help='Show the billing account') if resource == 'account' else listing(commands, paged=False)
38
+ command = (commands.add_parser('get', help='Show the billing account') if resource == 'account'
39
+ else listing(commands, paged=False, help=USAGE_HELP) if resource == 'usage' else listing(commands, paged=False))
27
40
  if resource == 'usage':
28
- command.add_argument('--start', required=True, type=billing_timestamp, help='Start: YYYY-MM-DD (UTC) or ISO timestamp with timezone')
29
- command.add_argument('--end', required=True, type=billing_timestamp, help='Exclusive end: YYYY-MM-DD (UTC) or ISO timestamp with timezone')
41
+ command.add_argument('--start', type=billing_timestamp,
42
+ help='Start: YYYY-MM-DD (UTC) or ISO timestamp with timezone (default: 7 days before the end)')
43
+ command.add_argument('--end', type=billing_timestamp,
44
+ help='Exclusive end: YYYY-MM-DD (UTC) or ISO timestamp with timezone (default: now, UTC)')
30
45
  command.add_argument('--group-by', choices=['day', 'product', 'endpoint', 'sku'])
31
46
  if resource == 'ledger':
32
47
  command.add_argument('--limit', type=int, default=25)
@@ -194,7 +209,8 @@ def execute(args: argparse.Namespace, client: Any) -> Any:
194
209
  return client.dashboard.get()
195
210
  if group == 'billing':
196
211
  if args.resource == 'usage':
197
- return client.billing.usage(start=args.start, end=args.end, group_by=args.group_by)
212
+ start, end = usage_window(args.start, args.end)
213
+ return client.billing.usage(start=start, end=end, group_by=args.group_by)
198
214
  if args.resource == 'ledger':
199
215
  return client.billing.ledger(limit=args.limit)
200
216
  return getattr(client.billing, {'account': 'account', 'price': 'prices'}[args.resource])()
@@ -222,9 +222,16 @@ class Data:
222
222
  if (isinstance(resource, dict) and resource.get('type') == data_type
223
223
  and resource.get('native_registered') is True and resource.get('native_ready') is True
224
224
  and isinstance(resource.get('data_id', resource.get('id')), str)
225
- and re.fullmatch(r'ans_[A-Za-z0-9_-]+', resource.get('data_id', resource.get('id')))):
225
+ # Resource IDs are opaque. Native completion is established by
226
+ # resource type and ready/registered state, never by an encoding prefix.
227
+ and re.fullmatch(r'[A-Za-z0-9_-]{1,8192}', resource.get('data_id', resource.get('id')))):
226
228
  return resource
227
- error = RemoteError('DATA_NATIVE_INCOMPLETE', 409)
229
+ # The platform answered successfully; this is the client's completion check, not an HTTP
230
+ # refusal. Report no HTTP status and only the platform's own bounded resource state.
231
+ returned = resource if isinstance(resource, dict) else {}
232
+ state = [f'{key}: {returned[key]}' for key in ('status', 'unavailable_reason')
233
+ if isinstance(returned.get(key), str) and re.fullmatch(r'[A-Za-z_]{1,64}', returned[key])]
234
+ error = RemoteError('DATA_NATIVE_INCOMPLETE', details=state)
228
235
  if isinstance(resource, dict):
229
236
  staging = resource.get('staging_id') or resource.get('data_id') or resource.get('id')
230
237
  if isinstance(staging, str) and re.fullmatch(r'pub_[A-Za-z0-9_-]{1,200}', staging):
@@ -83,15 +83,19 @@ def add_commands(groups: "argparse._SubParsersAction[argparse.ArgumentParser]",
83
83
  command.add_argument('--json', action='store_true')
84
84
 
85
85
 
86
+ MANIFEST_HELP = ('Native JSON content manifest; a record Dataset source.repository is https://github.com/OWNER/REPO '
87
+ 'or https://huggingface.co/datasets/ORG/NAME, with source.commit the full commit')
88
+
89
+
86
90
  def publication_arguments(command: argparse.ArgumentParser, *, source: bool = False):
87
91
  command.add_argument('--ref', required=not source, help='Exact immutable ANS URI')
88
92
  if source:
89
93
  inputs = command.add_mutually_exclusive_group(required=True)
90
- inputs.add_argument('--manifest', type=Path, help='Native JSON content manifest')
94
+ inputs.add_argument('--manifest', type=Path, help=MANIFEST_HELP)
91
95
  inputs.add_argument('--model', help='Public Hugging Face model namespace/name; metadata only')
92
96
  command.add_argument('--revision', help='Explicit HF source ref; the server freezes its full commit')
93
97
  else:
94
- command.add_argument('--manifest', type=Path, required=True, help='Native JSON content manifest')
98
+ command.add_argument('--manifest', type=Path, required=True, help=MANIFEST_HELP)
95
99
  command.add_argument('--directory', type=Path, required=not source, help='Directory containing exactly the declared files')
96
100
  command.add_argument('--executable', action='append', dest='executables', default=[],
97
101
  help='Declared Runner file that must be executable; repeat for each path')
@@ -33,8 +33,8 @@ def add_resource_commands(groups: "argparse._SubParsersAction[argparse.ArgumentP
33
33
  'immutable Runner revision NAME@REVISION. The platform stores the bytes, computes the package and index '
34
34
  'SHA-256, creates the Runner on first use and registers the revision; identical retries are idempotent and '
35
35
  'changed content for an existing revision is refused. This is el data create --type runner with the native '
36
- 'manifest built for you; use that command to publish an existing manifest. The key needs envloop:data:write '
37
- 'and runners.write, and publish permission on a Runner owned by another Project.')
36
+ 'manifest built for you; use that command to publish an existing manifest. The key needs runners.write '
37
+ '(el auth login issues it to a Developer or Admin), and publish permission on a Runner owned by another Project.')
38
38
  register.add_argument('directory', metavar='DIRECTORY', type=Path, help='Runner package directory; every file in it is published')
39
39
  register.add_argument('--revision', required=True, help='Full 40- or 64-character source commit, for example $(git rev-parse HEAD)')
40
40
  register.add_argument('--name', help='NAMESPACE/NAME; must equal [runner] name in runner.toml (default: that name)')
@@ -81,16 +81,21 @@ def execute(args: argparse.Namespace, client: Any):
81
81
  if group == 'checkpoint':
82
82
  if action == 'delete':
83
83
  result = client.checkpoints.delete(args.identifier)
84
- return client.operations.wait(result['operation_id'], timeout=args.timeout_seconds) if args.wait else result
84
+ if not args.wait:
85
+ return result
86
+ from envloop.lifecycle_cli import wait_after_request
87
+ return wait_after_request(client, result, timeout=args.timeout_seconds,
88
+ check=f'Check that el checkpoint list no longer shows {args.identifier}.')
85
89
  if action == 'download':
86
90
  return client.checkpoints.download(args.checkpoint, args.directory, job_id=args.job_id, trial_id=args.trial_id)
87
91
  return client.checkpoints.list(job_id=args.job_id, trial_id=args.trial_id,
88
92
  cursor=args.cursor, limit=args.limit)
89
93
 
90
94
 
91
- RUNNER_WRITE_HINT = ('Registering a Runner revision needs a project key with Data write (envloop:data:write) and '
92
- 'Runners write (runners.write), and publish or admin permission when another project owns the '
93
- 'Runner. Existing keys keep their original capabilities; issue a new key with these permissions.')
95
+ RUNNER_WRITE_HINT = ('Registering a Runner revision needs a project key with Runners write (runners.write), and '
96
+ 'publish or admin permission when another project owns the Runner. Existing keys keep their '
97
+ 'original capabilities; run el auth login without --read-only (Developer or Admin role) to issue '
98
+ 'a new key with it.')
94
99
 
95
100
 
96
101
  def register_runner(client: Any, args: argparse.Namespace):
@@ -102,7 +107,7 @@ def register_runner(client: Any, args: argparse.Namespace):
102
107
  executables=args.executables, source_ref=args.source_ref,
103
108
  source_ref_kind=args.source_ref_kind, dry_run=args.dry_run)
104
109
  except RemoteError as error:
105
- if error.code == 'ACCESS_DENIED' and not error.hint:
110
+ if error.code in {'ACCESS_DENIED', 'DATA_ACCESS_REQUIRED'} and not error.hint:
106
111
  error.hint = RUNNER_WRITE_HINT
107
112
  raise
108
113
  if not args.dry_run:
@@ -5,6 +5,9 @@ import sys
5
5
  from pathlib import Path
6
6
  from typing import Any
7
7
 
8
+ # Single-resource reads and actions print the response as JSON already; scripts may still pass --json.
9
+ JSON_DEFAULT_HELP = "Output the complete response as JSON (the default; accepted for consistency with list commands)"
10
+
8
11
 
9
12
  def dataset_reference(value: str):
10
13
  name, separator, version = value.rpartition("@")
@@ -155,6 +158,8 @@ def add_commands(groups: "argparse._SubParsersAction[argparse.ArgumentParser]"):
155
158
  if action == 'event':
156
159
  command.add_argument('--json', action='store_true')
157
160
  command.set_defaults(_list_view=True)
161
+ else:
162
+ command.add_argument("--json", action="store_true", help=JSON_DEFAULT_HELP)
158
163
  regrade = jobs.add_parser("regrade", help="Re-run only the verifier of sealed compose Trials, optionally with a reward set",
159
164
  allow_abbrev=False)
160
165
  regrade_source = regrade.add_mutually_exclusive_group(required=True)
@@ -218,7 +223,9 @@ def add_commands(groups: "argparse._SubParsersAction[argparse.ArgumentParser]"):
218
223
  snapshot = tasks.add_parser("snapshot")
219
224
  snapshot.add_argument("trial_id", metavar="TRIAL_ID")
220
225
  for action in ("get", "stop", "result"):
221
- tasks.add_parser(action).add_argument("trial_id", metavar="TRIAL_ID")
226
+ command = tasks.add_parser(action)
227
+ command.add_argument("trial_id", metavar="TRIAL_ID")
228
+ command.add_argument("--json", action="store_true", help=JSON_DEFAULT_HELP)
222
229
  listing.add_argument("--json", action="store_true", help="Output the complete response as JSON")
223
230
  artifacts = tasks.add_parser("artifact", help="List or download a Trial's artifacts").add_subparsers(dest="artifact_action", required=True)
224
231
  artifact_list = artifacts.add_parser("list", help="List one page of artifacts")
@@ -440,11 +447,18 @@ def execute_job(args: argparse.Namespace, client: Any, cli: argparse.ArgumentPar
440
447
  else:
441
448
  result = getattr(client.jobs, 'status' if args.action == 'get' else args.action)(args.job_id)
442
449
  if args.action == 'get':
450
+ from envloop_experiment.inference import service_line
443
451
  from envloop_experiment.train_metrics_cli import availability_line
444
- from envloop_experiment.train_progress import job_progress, progress_line
452
+ from envloop_experiment.train_progress import (
453
+ job_progress,
454
+ progress_line,
455
+ waiting_line,
456
+ )
445
457
 
446
- # The JSON on stdout is unchanged; a Train Job's live progress is a readable stderr line.
447
- for line in (progress_line(job_progress(result)), availability_line(result)):
458
+ # The JSON on stdout is unchanged; a Train Job's queue wait and live progress and an Inference
459
+ # Job's service state are readable stderr lines.
460
+ for line in (waiting_line(result), progress_line(job_progress(result)), availability_line(result),
461
+ service_line(result)):
448
462
  if line:
449
463
  print(line, file=sys.stderr)
450
464
  if args.action in {'get', 'result'}:
@@ -0,0 +1,79 @@
1
+ """Public inference intent validation and a one-line Job view; resolution and admission remain server owned."""
2
+ import re
3
+ from typing import Any
4
+
5
+ from envloop.terminal import safe_text
6
+
7
+ _FIELDS = {'name', 'runner', 'inputs', 'serving_profile', 'hardware', 'weights_format',
8
+ 'scaling', 'temporary_scope_id', 'profile', 'resources'}
9
+
10
+
11
+ def validate_submission(body: Any):
12
+ if (not isinstance(body, dict) or set(body) - {'type', 'request_id', 'spec'}
13
+ or body.get('type', 'inference') != 'inference'):
14
+ raise ValueError('inference submission requires spec and request_id')
15
+ spec = body.get('spec')
16
+ if not isinstance(spec, dict):
17
+ raise ValueError('inference submission requires a spec object')
18
+ if 'request_id' in body and (not isinstance(body['request_id'], str)
19
+ or not body['request_id'].strip() or len(body['request_id']) > 128):
20
+ raise ValueError('request_id must be a non-empty string of at most 128 characters')
21
+ if any(key in spec for key in ('provider', 'executor', 'resolved_runner', 'runtime')):
22
+ raise ValueError('execution configuration and Runner resolution are owned by the server')
23
+ if 'runner' not in spec:
24
+ return # Explicit legacy compatibility: existing adapter specs still reach the API.
25
+ if set(spec) - _FIELDS:
26
+ raise ValueError('fixed-input inference cannot mix legacy or runtime fields')
27
+ runner = spec['runner']
28
+ if (not isinstance(runner, str)
29
+ or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*@[^\s@]+', runner)):
30
+ raise ValueError('runner requires namespace/name@revision')
31
+ if not isinstance(spec.get('name'), str) or not spec['name'].strip():
32
+ raise ValueError('fixed-input inference requires name')
33
+ profile = spec.get('profile')
34
+ recipe_driven = (isinstance(profile, dict) and profile.get('schema') == 'envloop.job-profile/v2'
35
+ and isinstance(profile.get('recipe'), dict))
36
+ if ('serving_profile' in spec or not recipe_driven) and (
37
+ not isinstance(spec.get('serving_profile'), str) or not spec['serving_profile'].strip()):
38
+ raise ValueError('fixed-input inference requires serving_profile or an executable Recipe profile')
39
+ if 'resources' in spec:
40
+ resources = spec['resources']
41
+ bounds = {'gpus': (0, 8), 'cpus': (1, 256), 'memory_mb': (512, 2097152),
42
+ 'storage_mb': (1024, 16777216), 'shm_mb': (1, 2097152)}
43
+ if (not isinstance(resources, dict) or resources.keys() - (bounds.keys() | {'gpu_type'})
44
+ or any(type(value) is not int or not bounds[key][0] <= value <= bounds[key][1]
45
+ for key, value in resources.items() if key in bounds)
46
+ or 'gpu_type' in resources and (not isinstance(resources['gpu_type'], str)
47
+ or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,63}', resources['gpu_type']))):
48
+ raise ValueError('invalid inference resources override')
49
+ inputs = spec.get('inputs', {})
50
+ if (not isinstance(inputs, dict) or set(inputs) - {'checkpoint'}
51
+ or 'checkpoint' in inputs and (not isinstance(inputs['checkpoint'], str)
52
+ or not inputs['checkpoint'].startswith('envloop://') or not inputs['checkpoint'][10:])):
53
+ raise ValueError('inputs accepts one Checkpoint ANS URI')
54
+
55
+
56
+ def _word(value: Any) -> str | None:
57
+ return " ".join(safe_text(value).split())[:200] if isinstance(value, str) and value.strip() else None
58
+
59
+
60
+ def service_line(job: Any) -> str | None:
61
+ """``Inference: READY, replicas 1 ready / 1 active, model ws/chat`` for an Inference Job, else None.
62
+
63
+ A stopped Job adds whether its resource release is confirmed; a value never reported is omitted.
64
+ """
65
+ inference = job.get("inference") if isinstance(job, dict) and job.get("type") == "inference" else None
66
+ if not isinstance(inference, dict):
67
+ return None
68
+ parts = [_word(inference.get("service_status")) or "status unknown"]
69
+ replicas = inference.get("replicas")
70
+ if isinstance(replicas, dict) and all(type(replicas.get(key)) is int for key in ("ready", "active")):
71
+ parts.append(f"replicas {replicas['ready']} ready / {replicas['active']} active")
72
+ if model := _word(inference.get("model")):
73
+ parts.append(f"model {model}")
74
+ if error := _word(inference.get("error_code")):
75
+ parts.append(f"error {error}")
76
+ if job.get("status") in {"cancelled", "failed", "succeeded", "completed"} or parts[0] == "DELETED":
77
+ confirmed = inference.get("cleanup_confirmed")
78
+ parts.append("cleanup confirmed" if confirmed is True else "cleanup not confirmed yet")
79
+ return "Inference: " + ", ".join(parts)
@@ -29,9 +29,13 @@ def train_dataset(spec: dict[str, Any], dataset: str | None, split: str | None)
29
29
  if not separator:
30
30
  raise ValueError('Train --dataset needs an exact NAMESPACE/NAME@VERSION')
31
31
  if (name, version) != (current.get('name'), current.get('version')):
32
- selector.pop('manifest_sha256', None) # a pin belongs to the version it was read from
32
+ for pin in ('manifest_sha256', 'sha256', 'row_count'):
33
+ selector.pop(pin, None) # a pin belongs to the version it was read from
33
34
  selector.update(name=name, version=version)
34
35
  if split is not None:
36
+ if split != current.get('split'):
37
+ for pin in ('sha256', 'row_count'):
38
+ selector.pop(pin, None) # a frozen spec's split pins name that split's rows only
35
39
  selector['split'] = split
36
40
  if not {'name', 'version', 'split'} <= set(selector):
37
41
  raise ValueError('Train --dataset needs NAMESPACE/NAME@VERSION and --split')
@@ -35,4 +35,8 @@ def run_job_resource(client: Any, args: argparse.Namespace):
35
35
  # The platform requires an idempotency key; generate one like the SDK's Adapters.register.
36
36
  return resource.create({'request_id': str(uuid4())} | body if isinstance(body, dict) else body)
37
37
  result = resource.action(args.identifier, 'delete')
38
- return client.operations.wait(result['operation_id'], timeout=args.timeout_seconds) if args.wait else result
38
+ if not args.wait:
39
+ return result
40
+ from envloop.lifecycle_cli import wait_after_request
41
+ return wait_after_request(client, result, timeout=args.timeout_seconds,
42
+ check=f'Check with: el job adapter get {args.identifier} (404 = deleted).')
@@ -30,10 +30,13 @@ def log_line(item: dict[str, Any], *, color: bool = False):
30
30
  if item.get("phase"):
31
31
  diagnostic += f"; phase={safe_text(item['phase'])}"
32
32
  message = f"[{diagnostic}] {message}"
33
+ # The line had no write time and was read after the Job ended: its time is when it was collected.
34
+ backfilled = "(backfilled)" if item.get("backfilled") is True else ""
33
35
  if color:
34
36
  timestamp = style(timestamp, "2") if timestamp else ""
37
+ backfilled = style(backfilled, "2") if backfilled else ""
35
38
  level = status_text(level)
36
- prefix = " ".join(part for part in (timestamp, level) if part)
39
+ prefix = " ".join(part for part in (timestamp, backfilled, level) if part)
37
40
  return f"{prefix} {message}"
38
41
 
39
42
 
@@ -6,7 +6,7 @@ import math
6
6
  import re
7
7
  import sys
8
8
  import time
9
- from datetime import datetime
9
+ from datetime import datetime, timezone
10
10
  from typing import Any
11
11
 
12
12
  from envloop.cli_output import display, table
@@ -20,7 +20,7 @@ FOLLOW_RETRIES = 3
20
20
  TERMINAL = {"completed", "succeeded", "failed", "cancelled", "SUCCEEDED", "FAILED", "CANCELLED"}
21
21
  HEADERS = ("STEP", "LOSS", "LEARNING RATE", "GRAD NORM", "EPOCH", "TOKENS", "SECONDS/STEP", "OBSERVED AT")
22
22
  # Column widths of --follow, which prints rows before later ones are known.
23
- WIDTHS = (6, 10, 13, 10, 8, 12, 12, 19)
23
+ WIDTHS = (6, 10, 13, 10, 8, 12, 12, 23)
24
24
 
25
25
 
26
26
  def _number(value: Any) -> float | None:
@@ -41,8 +41,9 @@ def _value(value: Any, spec: str) -> str | None:
41
41
 
42
42
 
43
43
  def _observed(value: Any) -> str | None:
44
+ """``at`` is Unix time; like every table time it is shown in UTC, labelled (--json keeps the number)."""
44
45
  number = _number(value)
45
- return None if number is None else datetime.fromtimestamp(number).strftime("%Y-%m-%d %H:%M:%S")
46
+ return None if number is None else datetime.fromtimestamp(number, timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
46
47
 
47
48
 
48
49
  def step_row(point: dict[str, Any], *, model: bool = False) -> tuple[object, ...]:
@@ -1,4 +1,4 @@
1
- """One-line view of a Train Job's live ``training.progress``; reading never changes the Job."""
1
+ """One-line views of a Train Job's queue wait and live ``training.progress``; reading never changes the Job."""
2
2
 
3
3
  import math
4
4
  from typing import Any
@@ -50,3 +50,19 @@ def progress_line(progress: dict[str, Any] | None) -> str | None:
50
50
  if isinstance(progress.get("updated_at"), str):
51
51
  parts.append(f"updated {safe_text(compact_time(progress['updated_at'], utc=True))}")
52
52
  return "Training progress: " + ", ".join(parts)
53
+
54
+
55
+ # What a queued Train Job is waiting for, in words; other reasons are shown as the platform names them.
56
+ _WAITING = {"EXECUTION_CAPACITY_UNAVAILABLE": "no matching GPU is free yet. The Job keeps its place until the queue "
57
+ "limit, then fails with EXECUTION_QUEUE_TIMEOUT and costs nothing"}
58
+
59
+
60
+ def waiting_line(job: Any) -> str | None:
61
+ """``Waiting: EXECUTION_CAPACITY_UNAVAILABLE (...)`` while a Train Job waits in the queue, else None."""
62
+ training = job.get("training") if isinstance(job, dict) else None
63
+ reason = (training.get("waiting_reason") if isinstance(training, dict) else None) or (
64
+ job.get("waiting_reason") if isinstance(job, dict) else None)
65
+ if not isinstance(reason, str) or not reason.strip():
66
+ return None
67
+ reason = " ".join(safe_text(reason).split())[:200]
68
+ return f"Waiting: {reason}" + (f" ({_WAITING[reason]})" if reason in _WAITING else "")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "envloop-cli"
3
- version = "1.29.0"
3
+ version = "1.31.0"
4
4
  description = "EnvLoop CLI and Python SDK for jobs, training, checkpoints and inference"
5
5
  requires-python = ">=3.12"
6
6
  readme = "README.pypi.md"
@@ -65,7 +65,7 @@ combine-as-imports = true
65
65
  known-first-party = ["envloop", "envloop_data", "envloop_experiment", "envloop_rsi_loop"]
66
66
 
67
67
  [tool.uv.sources]
68
- envloop-envhub = { git = "ssh://git@github.com/EnvLoop/EnvHub.git", rev = "8c96b155d7ab0153da6359508a40c5a4fe802e78" }
68
+ envloop-envhub = { git = "ssh://git@github.com/EnvLoop/EnvHub.git", rev = "b346ac728626602da745a36249ab35f12f6d313f" }
69
69
 
70
70
  [tool.pyrefly]
71
71
  preset = "strict"
@@ -1,55 +0,0 @@
1
- """Project lifecycle commands nested under Console Settings."""
2
- import argparse
3
- import json
4
- from pathlib import Path
5
- from typing import Any
6
-
7
-
8
- def wait_options(parser: argparse.ArgumentParser):
9
- parser.add_argument('--wait', action='store_true', help='Wait for the persisted cleanup Operation')
10
- parser.add_argument('--timeout-seconds', type=float, default=600)
11
-
12
-
13
- def add_management_commands(settings: "argparse._SubParsersAction[argparse.ArgumentParser]"):
14
- operations = settings.add_parser('operation', help='Inspect or retry persisted cleanup').add_subparsers(dest='operation_action', required=True)
15
- for action in ('get', 'retry', 'wait'):
16
- command = operations.add_parser(action)
17
- command.add_argument('identifier', metavar='OPERATION_ID')
18
- if action == 'wait':
19
- command.add_argument('--timeout-seconds', type=float, default=600)
20
- cleanup = settings.add_parser('cleanup', help='Clean an explicit set of Project resource IDs')
21
- cleanup.add_argument('--file', type=Path, required=True, help='JSON array of resource kind and ID objects')
22
- cleanup.add_argument('--request-id', required=True)
23
- wait_options(cleanup)
24
- scopes = settings.add_parser('resource-scope', help='Manage temporary resources with server TTL').add_subparsers(dest='scope_action', required=True)
25
- create = scopes.add_parser('create')
26
- create.add_argument('--ttl-seconds', type=int, default=1800)
27
- create.add_argument('--request-id', required=True)
28
- for action in ('get', 'cleanup', 'retain'):
29
- command = scopes.add_parser(action)
30
- command.add_argument('identifier', metavar='RESOURCE_SCOPE_ID')
31
- if action == 'cleanup':
32
- wait_options(command)
33
- if action == 'retain':
34
- command.add_argument('--file', type=Path, required=True, help='JSON array of resources to retain')
35
-
36
-
37
- def run_management(client: Any, args: argparse.Namespace):
38
- from .resources import _id
39
-
40
- if args.action == 'operation':
41
- return (client.operations.wait(args.identifier, timeout=args.timeout_seconds) if args.operation_action == 'wait'
42
- else getattr(client.operations, args.operation_action)(args.identifier))
43
- if args.action == 'resource-scope':
44
- if args.scope_action == 'create':
45
- client.temporary_resources(ttl_seconds=args.ttl_seconds)
46
- base = client.operations.project_path + '/temporary-resource-scopes'
47
- if args.scope_action == 'create':
48
- result = client._transport.request('POST', base, {'ttl_seconds': args.ttl_seconds, 'request_id': args.request_id})
49
- else:
50
- path = base + '/' + _id(args.identifier)
51
- result = (client._transport.request('POST', path + '/retain', {'resources': json.loads(args.file.read_text())})
52
- if args.scope_action == 'retain' else client._transport.request('DELETE' if args.scope_action == 'cleanup' else 'GET', path))
53
- else:
54
- result = client.operations.cleanup(json.loads(args.file.read_text()), request_id=args.request_id)
55
- return client.operations.wait(result['operation_id'], timeout=args.timeout_seconds) if getattr(args, 'wait', False) else result
@@ -1,36 +0,0 @@
1
- """Public inference intent validation; resolution and admission remain server owned."""
2
- import re
3
- from typing import Any
4
-
5
- _FIELDS = {'name', 'runner', 'inputs', 'serving_profile', 'hardware', 'weights_format',
6
- 'scaling', 'temporary_scope_id', 'profile'}
7
-
8
-
9
- def validate_submission(body: Any):
10
- if (not isinstance(body, dict) or set(body) - {'type', 'request_id', 'spec'}
11
- or body.get('type', 'inference') != 'inference'):
12
- raise ValueError('inference submission requires spec and request_id')
13
- spec = body.get('spec')
14
- if not isinstance(spec, dict):
15
- raise ValueError('inference submission requires a spec object')
16
- if 'request_id' in body and (not isinstance(body['request_id'], str)
17
- or not body['request_id'].strip() or len(body['request_id']) > 128):
18
- raise ValueError('request_id must be a non-empty string of at most 128 characters')
19
- if any(key in spec for key in ('provider', 'executor', 'resolved_runner', 'runtime')):
20
- raise ValueError('execution configuration and Runner resolution are owned by the server')
21
- if 'runner' not in spec:
22
- return # Explicit legacy compatibility: existing adapter specs still reach the API.
23
- if set(spec) - _FIELDS:
24
- raise ValueError('fixed-input inference cannot mix legacy or runtime fields')
25
- runner = spec['runner']
26
- if (not isinstance(runner, str)
27
- or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*@[^\s@]+', runner)):
28
- raise ValueError('runner requires namespace/name@revision')
29
- if any(not isinstance(spec.get(key), str) or not spec[key].strip()
30
- for key in ('name', 'serving_profile')):
31
- raise ValueError('fixed-input inference requires name and serving_profile')
32
- inputs = spec.get('inputs', {})
33
- if (not isinstance(inputs, dict) or set(inputs) - {'checkpoint'}
34
- or 'checkpoint' in inputs and (not isinstance(inputs['checkpoint'], str)
35
- or not inputs['checkpoint'].startswith('envloop://') or not inputs['checkpoint'][10:])):
36
- raise ValueError('inputs accepts one Checkpoint ANS URI')
File without changes