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.
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/PKG-INFO +3 -3
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/README.pypi.md +2 -2
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/auth_cli.py +3 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/auth_login.py +4 -1
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/cli_errors.py +10 -3
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/http.py +10 -4
- envloop_cli-1.31.0/envloop/lifecycle_cli.py +118 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/resource_cli.py +20 -4
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/data.py +9 -2
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/data_cli.py +6 -2
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/resource_cli.py +12 -7
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/cli.py +18 -4
- envloop_cli-1.31.0/packages/experiment/envloop_experiment/inference.py +79 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/job_spec_cli.py +5 -1
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/resource_cli.py +5 -1
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/train_log_cli.py +4 -1
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/train_metrics_cli.py +4 -3
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/train_progress.py +17 -1
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/pyproject.toml +2 -2
- envloop_cli-1.29.0/envloop/lifecycle_cli.py +0 -55
- envloop_cli-1.29.0/packages/experiment/envloop_experiment/inference.py +0 -36
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/.gitignore +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/__init__.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/_optional.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/auth_workspace.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/cli.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/cli_output.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/client.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/config.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/downloads.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/navigation_resources.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/profile_secrets.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/resources.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/terminal.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/utils/__init__.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/envloop/utils/logging.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/__init__.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/cli.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/cli_output.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/client.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/data_publications.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/data_upload.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/http.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/navigation_resources.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/profile_updates.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/recipes.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/runner_packages.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/__init__.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/cli_output.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/client.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/decision.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/http.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/job_cost.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/job_logs.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/navigation_resources.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/resources.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/runtime_params.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/sandbox.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/train.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/train_runner_cli.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/training/__init__.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/rsi-loop/envloop_rsi_loop/__init__.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/rsi-loop/envloop_rsi_loop/agent_runs.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/rsi-loop/envloop_rsi_loop/cli.py +0 -0
- {envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/rsi-loop/envloop_rsi_loop/cli_output.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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'
|
|
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',
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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
|
|
37
|
-
'
|
|
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
|
-
|
|
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
|
|
92
|
-
'
|
|
93
|
-
'
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
447
|
-
|
|
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)
|
{envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/job_spec_cli.py
RENAMED
|
@@ -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
|
-
|
|
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')
|
{envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/resource_cli.py
RENAMED
|
@@ -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
|
-
|
|
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).')
|
{envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/train_log_cli.py
RENAMED
|
@@ -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,
|
|
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, ...]:
|
{envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/train_progress.py
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""One-line
|
|
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.
|
|
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 = "
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/data/envloop_data/navigation_resources.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/__init__.py
RENAMED
|
File without changes
|
{envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/cli_output.py
RENAMED
|
File without changes
|
|
File without changes
|
{envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/decision.py
RENAMED
|
File without changes
|
|
File without changes
|
{envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/job_cost.py
RENAMED
|
File without changes
|
{envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/job_logs.py
RENAMED
|
File without changes
|
|
File without changes
|
{envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/resources.py
RENAMED
|
File without changes
|
{envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/runtime_params.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{envloop_cli-1.29.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/train_runner_cli.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|