envloop-cli 1.30.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.30.0 → envloop_cli-1.31.0}/PKG-INFO +1 -1
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/envloop/cli_errors.py +3 -0
- {envloop_cli-1.30.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.30.0 → envloop_cli-1.31.0}/envloop/resource_cli.py +20 -4
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/data/envloop_data/data.py +9 -2
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/data/envloop_data/resource_cli.py +5 -1
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/cli.py +10 -3
- envloop_cli-1.31.0/packages/experiment/envloop_experiment/inference.py +79 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/job_spec_cli.py +5 -1
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/resource_cli.py +5 -1
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/train_progress.py +17 -1
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/pyproject.toml +2 -2
- envloop_cli-1.30.0/envloop/lifecycle_cli.py +0 -57
- envloop_cli-1.30.0/packages/experiment/envloop_experiment/inference.py +0 -36
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/.gitignore +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/README.pypi.md +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/envloop/__init__.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/envloop/_optional.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/envloop/auth_cli.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/envloop/auth_login.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/envloop/auth_workspace.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/envloop/cli.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/envloop/cli_output.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/envloop/client.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/envloop/config.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/envloop/downloads.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/envloop/navigation_resources.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/envloop/profile_secrets.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/envloop/resources.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/envloop/terminal.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/envloop/utils/__init__.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/envloop/utils/logging.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/data/envloop_data/__init__.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/data/envloop_data/cli.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/data/envloop_data/cli_output.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/data/envloop_data/client.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/data/envloop_data/data_cli.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/data/envloop_data/data_publications.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/data/envloop_data/data_upload.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/data/envloop_data/http.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/data/envloop_data/navigation_resources.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/data/envloop_data/profile_updates.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/data/envloop_data/recipes.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/data/envloop_data/runner_packages.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/__init__.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/cli_output.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/client.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/decision.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/http.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/job_cost.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/job_logs.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/navigation_resources.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/resources.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/runtime_params.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/sandbox.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/train.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/train_log_cli.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/train_metrics_cli.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/train_runner_cli.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/training/__init__.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/rsi-loop/envloop_rsi_loop/__init__.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/rsi-loop/envloop_rsi_loop/agent_runs.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/rsi-loop/envloop_rsi_loop/cli.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/rsi-loop/envloop_rsi_loop/cli_output.py +0 -0
- {envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/rsi-loop/envloop_rsi_loop/rsi_cli.py +0 -0
|
@@ -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.
|
|
@@ -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):
|
|
@@ -81,7 +81,11 @@ 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,
|
|
@@ -447,11 +447,18 @@ def execute_job(args: argparse.Namespace, client: Any, cli: argparse.ArgumentPar
|
|
|
447
447
|
else:
|
|
448
448
|
result = getattr(client.jobs, 'status' if args.action == 'get' else args.action)(args.job_id)
|
|
449
449
|
if args.action == 'get':
|
|
450
|
+
from envloop_experiment.inference import service_line
|
|
450
451
|
from envloop_experiment.train_metrics_cli import availability_line
|
|
451
|
-
from envloop_experiment.train_progress import
|
|
452
|
+
from envloop_experiment.train_progress import (
|
|
453
|
+
job_progress,
|
|
454
|
+
progress_line,
|
|
455
|
+
waiting_line,
|
|
456
|
+
)
|
|
452
457
|
|
|
453
|
-
# The JSON on stdout is unchanged; a Train Job's live progress
|
|
454
|
-
|
|
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)):
|
|
455
462
|
if line:
|
|
456
463
|
print(line, file=sys.stderr)
|
|
457
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.30.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.30.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.30.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,57 +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,
|
|
22
|
-
help='JSON array of {"kind": KIND, "id": ID} objects; KIND is job (a Train Job), endpoint, adapter '
|
|
23
|
-
'or checkpoint, for example [{"kind": "job", "id": "job_..."}]')
|
|
24
|
-
cleanup.add_argument('--request-id', required=True)
|
|
25
|
-
wait_options(cleanup)
|
|
26
|
-
scopes = settings.add_parser('resource-scope', help='Manage temporary resources with server TTL').add_subparsers(dest='scope_action', required=True)
|
|
27
|
-
create = scopes.add_parser('create')
|
|
28
|
-
create.add_argument('--ttl-seconds', type=int, default=1800)
|
|
29
|
-
create.add_argument('--request-id', required=True)
|
|
30
|
-
for action in ('get', 'cleanup', 'retain'):
|
|
31
|
-
command = scopes.add_parser(action)
|
|
32
|
-
command.add_argument('identifier', metavar='RESOURCE_SCOPE_ID')
|
|
33
|
-
if action == 'cleanup':
|
|
34
|
-
wait_options(command)
|
|
35
|
-
if action == 'retain':
|
|
36
|
-
command.add_argument('--file', type=Path, required=True, help='JSON array of resources to retain')
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def run_management(client: Any, args: argparse.Namespace):
|
|
40
|
-
from .resources import _id
|
|
41
|
-
|
|
42
|
-
if args.action == 'operation':
|
|
43
|
-
return (client.operations.wait(args.identifier, timeout=args.timeout_seconds) if args.operation_action == 'wait'
|
|
44
|
-
else getattr(client.operations, args.operation_action)(args.identifier))
|
|
45
|
-
if args.action == 'resource-scope':
|
|
46
|
-
if args.scope_action == 'create':
|
|
47
|
-
client.temporary_resources(ttl_seconds=args.ttl_seconds)
|
|
48
|
-
base = client.operations.project_path + '/temporary-resource-scopes'
|
|
49
|
-
if args.scope_action == 'create':
|
|
50
|
-
result = client._transport.request('POST', base, {'ttl_seconds': args.ttl_seconds, 'request_id': args.request_id})
|
|
51
|
-
else:
|
|
52
|
-
path = base + '/' + _id(args.identifier)
|
|
53
|
-
result = (client._transport.request('POST', path + '/retain', {'resources': json.loads(args.file.read_text())})
|
|
54
|
-
if args.scope_action == 'retain' else client._transport.request('DELETE' if args.scope_action == 'cleanup' else 'GET', path))
|
|
55
|
-
else:
|
|
56
|
-
result = client.operations.cleanup(json.loads(args.file.read_text()), request_id=args.request_id)
|
|
57
|
-
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{envloop_cli-1.30.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.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/__init__.py
RENAMED
|
File without changes
|
{envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/cli_output.py
RENAMED
|
File without changes
|
|
File without changes
|
{envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/decision.py
RENAMED
|
File without changes
|
|
File without changes
|
{envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/job_cost.py
RENAMED
|
File without changes
|
{envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/job_logs.py
RENAMED
|
File without changes
|
|
File without changes
|
{envloop_cli-1.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/resources.py
RENAMED
|
File without changes
|
{envloop_cli-1.30.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.30.0 → envloop_cli-1.31.0}/packages/experiment/envloop_experiment/train_log_cli.py
RENAMED
|
File without changes
|
|
File without changes
|
{envloop_cli-1.30.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
|