envloop-cli 1.28.0__tar.gz → 1.29.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/PKG-INFO +1 -1
  2. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/_optional.py +1 -1
  3. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/cli_errors.py +1 -1
  4. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/cli_output.py +2 -1
  5. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/terminal.py +8 -5
  6. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/cli_output.py +5 -4
  7. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/client.py +2 -2
  8. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/data.py +35 -35
  9. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/data_cli.py +8 -8
  10. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/data_publications.py +6 -6
  11. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/data_upload.py +1 -1
  12. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/navigation_resources.py +7 -5
  13. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/resource_cli.py +5 -5
  14. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/cli.py +40 -8
  15. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/job_cost.py +10 -7
  16. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/job_spec_cli.py +34 -3
  17. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/train.py +7 -2
  18. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/train_log_cli.py +1 -1
  19. envloop_cli-1.29.0/packages/experiment/envloop_experiment/train_metrics_cli.py +145 -0
  20. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/train_progress.py +1 -1
  21. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/pyproject.toml +1 -1
  22. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/.gitignore +0 -0
  23. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/README.pypi.md +0 -0
  24. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/__init__.py +0 -0
  25. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/auth_cli.py +0 -0
  26. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/auth_login.py +0 -0
  27. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/auth_workspace.py +0 -0
  28. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/cli.py +0 -0
  29. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/client.py +0 -0
  30. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/config.py +0 -0
  31. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/downloads.py +0 -0
  32. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/http.py +0 -0
  33. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/lifecycle_cli.py +0 -0
  34. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/navigation_resources.py +0 -0
  35. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/profile_secrets.py +0 -0
  36. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/resource_cli.py +0 -0
  37. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/resources.py +0 -0
  38. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/utils/__init__.py +0 -0
  39. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/utils/logging.py +0 -0
  40. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/__init__.py +0 -0
  41. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/cli.py +0 -0
  42. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/http.py +0 -0
  43. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/profile_updates.py +0 -0
  44. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/recipes.py +0 -0
  45. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/runner_packages.py +0 -0
  46. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/__init__.py +0 -0
  47. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/cli_output.py +0 -0
  48. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/client.py +0 -0
  49. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/decision.py +0 -0
  50. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/http.py +0 -0
  51. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/inference.py +0 -0
  52. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/job_logs.py +0 -0
  53. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/navigation_resources.py +0 -0
  54. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/resource_cli.py +0 -0
  55. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/resources.py +0 -0
  56. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/runtime_params.py +0 -0
  57. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/sandbox.py +0 -0
  58. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/train_runner_cli.py +0 -0
  59. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/training/__init__.py +0 -0
  60. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/rsi-loop/envloop_rsi_loop/__init__.py +0 -0
  61. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/rsi-loop/envloop_rsi_loop/agent_runs.py +0 -0
  62. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/rsi-loop/envloop_rsi_loop/cli.py +0 -0
  63. {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/rsi-loop/envloop_rsi_loop/cli_output.py +0 -0
  64. {envloop_cli-1.28.0 → envloop_cli-1.29.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.28.0
3
+ Version: 1.29.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
@@ -30,7 +30,7 @@ FEATURE_COMMANDS = {
30
30
 
31
31
  MODULES: dict[str, str] = {
32
32
  **dict.fromkeys('data data_publications data_upload data_cli profile_updates recipes'.split(), 'data'),
33
- **dict.fromkeys('train train_progress train_runner_cli train_log_cli job_logs job_cost inference decision sandbox runtime_params job_spec_cli training'.split(), 'experiment'),
33
+ **dict.fromkeys('train train_progress train_metrics_cli train_runner_cli train_log_cli job_logs job_cost inference decision sandbox runtime_params job_spec_cli training'.split(), 'experiment'),
34
34
  **dict.fromkeys('agent_runs rsi_cli'.split(), 'rsi-loop'),
35
35
  }
36
36
  CLIENTS = {
@@ -182,7 +182,7 @@ TASK_REQUEST_CONFLICT TEMPORARY_DEPENDENCY_REQUIRES_RETAIN TEMPORARY_RESOURCE_RE
182
182
  TEMPORARY_SCOPE_CONFLICT TEMPORARY_SCOPE_EXPIRED TRIAL_IDEMPOTENCY_CONFLICT TRIAL_VERSION_CONFLICT USAGE_EVENT_CONFLICT
183
183
  PROFILE_CORRUPT ADAPTER_STORAGE_UNAVAILABLE INSUFFICIENT_BALANCE METHOD_NOT_ALLOWED REQUEST_TOO_LARGE
184
184
  BATCH_POLICY_NOT_APPROVED BATCH_PROVIDER_UNAVAILABLE CHECKPOINT_ACCESS_DENIED CHECKPOINT_DOWNLOAD_UNAVAILABLE
185
- CHECKPOINT_MANAGEMENT_UNAVAILABLE CHECKPOINT_MANAGEMENT_UNSUPPORTED CHECKPOINT_NOT_DURABLE CHECKPOINT_RUN_REQUIRED
185
+ CHECKPOINT_MANAGEMENT_UNAVAILABLE CHECKPOINT_MANAGEMENT_UNSUPPORTED CHECKPOINT_NOT_DURABLE CHECKPOINT_TRIAL_REQUIRED
186
186
  CHECKPOINT_SIGNER_UNAVAILABLE INVALID_CHECKPOINT_CURSOR INVALID_CHECKPOINT_TTL INVALID_CHECKPOINT_VISIBILITY
187
187
  INVALID_EXECUTION_CONFIG INVALID_METRICS_RANGE INVALID_TRAIN_JOB_CURSOR INVALID_TRAIN_JOB_LIST_LIMIT
188
188
  INVALID_TRAIN_JOB_METADATA INVALID_TRAIN_JOB_SPEC INVALID_TRAIN_JOB_STATUS LOG_RETENTION_EXPIRED
@@ -38,7 +38,8 @@ def table(headers: Sequence[object], rows: Sequence[Sequence[object]]) -> str:
38
38
 
39
39
 
40
40
  def timestamp(value: object) -> str:
41
- return compact_time(value, local=True)
41
+ """Table and summary times: UTC to seconds, labelled; --json keeps the original value."""
42
+ return compact_time(value, utc=True)
42
43
 
43
44
 
44
45
  def next_page(args: argparse.Namespace, cursor: object, program: str) -> str:
@@ -6,7 +6,7 @@ import sys
6
6
  import unicodedata
7
7
  from contextlib import contextmanager
8
8
  from contextvars import ContextVar
9
- from datetime import datetime
9
+ from datetime import datetime, timezone
10
10
  from typing import Any, TextIO
11
11
 
12
12
  _NO_COLOR = ContextVar("envloop_cli_no_color", default=False)
@@ -47,15 +47,18 @@ def status_text(text: str) -> str:
47
47
  return style(text, code) if code else text
48
48
 
49
49
 
50
- def compact_time(value: Any, *, local: bool = False) -> Any:
51
- """Render aware times to seconds; preserve unknown or timezone-free values."""
50
+ def compact_time(value: Any, *, utc: bool = False) -> Any:
51
+ """Render aware times to seconds; preserve unknown or timezone-free values.
52
+
53
+ ``utc`` converts to UTC and labels it, so a table read on any machine means the same instant.
54
+ """
52
55
  if not isinstance(value, str):
53
56
  return value
54
57
  try:
55
58
  parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
56
59
  if parsed.tzinfo is not None:
57
- if local:
58
- parsed = parsed.astimezone()
60
+ if utc:
61
+ return parsed.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
59
62
  return parsed.strftime("%Y-%m-%d %H:%M:%S")
60
63
  except ValueError:
61
64
  pass
@@ -38,9 +38,10 @@ def list_layout(items: list[dict[str, Any]], args: argparse.Namespace) -> tuple[
38
38
  render = dataset_row
39
39
  empty = 'No datasets found.'
40
40
  elif args.group == 'checkpoint':
41
- headers = ('CHECKPOINT ID', 'TYPE', 'MODEL', 'JOB ID', 'URI')
42
- render = lambda item: (item.get('checkpoint_id'), item.get('checkpoint_type'), item.get('base_model'),
43
- item.get('job_id'), item.get('uri'))
41
+ # The checkpoint ID is the resource_id (cp_…); the save name (adapter-final) repeats across Jobs.
42
+ headers = ('CHECKPOINT ID', 'NAME', 'TYPE', 'MODEL', 'JOB ID', 'URI')
43
+ render = lambda item: (item.get('resource_id'), item.get('checkpoint_id'), item.get('checkpoint_type'),
44
+ item.get('base_model'), item.get('job_id'), item.get('uri'))
44
45
  empty = 'No checkpoints found.'
45
46
  elif args.group == "profile" and args.name is None:
46
47
  headers = ("NAME", "VERSIONS", "DEFAULT", "UPDATED AT")
@@ -64,7 +65,7 @@ def pagination(command: list[str], args: argparse.Namespace, cursor: object) ->
64
65
  command.extend(['--limit', str(args.limit), '--cursor=' + str(cursor)])
65
66
  fields = {'dataset': ('type', 'query'),
66
67
  'data': ('type', 'ref', 'job_id', 'trial_id'),
67
- 'checkpoint': ('job_id', 'run_id'), 'profile': ('name',)}
68
+ 'checkpoint': ('job_id', 'trial_id'), 'profile': ('name',)}
68
69
  for field in fields.get(args.group, ()):
69
70
  if getattr(args, field, None) is not None:
70
71
  if field == 'query':
@@ -194,7 +194,7 @@ class Runners:
194
194
  source_ref_kind: str = 'branch', dry_run: bool = False):
195
195
  """Publish a Runner package directory as the immutable revision NAME@REVISION.
196
196
 
197
- This is `client.data.create(kind='runner', ...)` with the native manifest built
197
+ This is `client.data.create(type='runner', ...)` with the native manifest built
198
198
  from `directory` (runner.toml plus its main.py or main.sh entrypoint): the
199
199
  platform stores the bytes, derives the package and index SHA-256, creates the
200
200
  Runner asset on first use and registers the revision. `revision` is the full
@@ -219,7 +219,7 @@ class Runners:
219
219
  raise RemoteError('RUNNER_SOURCE_REF_INVALID')
220
220
  name, manifest, files, executables = runner_package(directory, name=name, executables=executables)
221
221
  ref = f'envloop://runners/{name}@{revision}'
222
- publication = Data(self._server).create(kind='runner', ref=ref, manifest=manifest, files=files,
222
+ publication = Data(self._server).create(type='runner', ref=ref, manifest=manifest, files=files,
223
223
  executables=executables, dry_run=dry_run)
224
224
  if dry_run:
225
225
  return publication
@@ -114,10 +114,10 @@ class Data:
114
114
  raise ValueError('limit must be between 1 and 100')
115
115
  return path + '?' + urlencode({key: value for key, value in params.items() if value is not None})
116
116
 
117
- def list(self, *, kind: Literal['dataset', 'artifact', 'checkpoint', 'runner', 'recipe'], ref: str | None = None, job_id: str | None = None, trial_id: str | None = None, limit: int = 25, cursor: str | None = None) -> dict[str, Any]:
118
- if kind not in ('dataset', 'artifact', 'checkpoint', 'runner', 'recipe') or (kind != 'artifact' and (job_id or trial_id)):
119
- raise ValueError('select a Data kind and appropriate owner filters')
120
- return self.transport.request('GET', self._query(self._path(), kind=kind, ref=ref, job_id=job_id, trial_id=trial_id, limit=limit, cursor=cursor))
117
+ def list(self, *, type: Literal['dataset', 'artifact', 'checkpoint', 'runner', 'recipe'], ref: str | None = None, job_id: str | None = None, trial_id: str | None = None, limit: int = 25, cursor: str | None = None) -> dict[str, Any]:
118
+ if type not in ('dataset', 'artifact', 'checkpoint', 'runner', 'recipe') or (type != 'artifact' and (job_id or trial_id)):
119
+ raise ValueError('select a Data type and appropriate owner filters')
120
+ return self.transport.request('GET', self._query(self._path(), type=type, ref=ref, job_id=job_id, trial_id=trial_id, limit=limit, cursor=cursor))
121
121
 
122
122
  def get(self, data_id: str) -> dict[str, Any]:
123
123
  return self.transport.request('GET', self._path(data_id))
@@ -131,16 +131,16 @@ class Data:
131
131
  from envhub.ans.proto import ANSError, DatasetRef, InvalidManifest, parse_uri
132
132
 
133
133
  reference = parse_uri(uri)
134
- kinds = {'datasets':'dataset', 'artifacts':'artifact', 'model':'checkpoint',
134
+ data_types = {'datasets':'dataset', 'artifacts':'artifact', 'model':'checkpoint',
135
135
  'runners':'runner', 'recipes':'recipe'}
136
- if reference.kind not in kinds or reference.uri != uri:
136
+ if reference.kind not in data_types or reference.uri != uri:
137
137
  raise ValueError('select a complete resource ANS URI')
138
138
  if isinstance(reference, DatasetRef) and (not isinstance(split, str) or not split):
139
139
  raise ValueError('Dataset download requires a split')
140
140
  if not isinstance(reference, DatasetRef) and split is not None:
141
141
  raise ValueError('split is only supported for Dataset')
142
142
  endpoint = self._path() + '/download'
143
- parameters = {'kind': kinds[reference.kind], 'ref': uri, 'split': split}
143
+ parameters = {'type': data_types[reference.kind], 'ref': uri, 'split': split}
144
144
  plan = self.transport.request('GET', self._query(endpoint, **parameters))
145
145
  from envhub.ans.client import validate_download_plan
146
146
  try:
@@ -179,36 +179,36 @@ class Data:
179
179
  return {'ref': uri, 'split': split, 'content_digest': plan['content_digest'],
180
180
  'directory': str(target), 'verified': True}
181
181
 
182
- def create(self, *, kind: str, ref: str | None = None, manifest: dict[str, Any] | None = None, files: dict[str, Any] | None = None, model: str | None = None, revision: str | None = None,
182
+ def create(self, *, type: str, ref: str | None = None, manifest: dict[str, Any] | None = None, files: dict[str, Any] | None = None, model: str | None = None, revision: str | None = None,
183
183
  executables: Sequence[str] = (), dry_run: bool = False, idempotency_key: str | None = None):
184
184
  """Register native content or frozen public-HF metadata in this project."""
185
185
  from envloop_data.data_publications import hf_source
186
186
 
187
- source = hf_source(kind, model, revision, ref=ref, manifest=manifest, files=files,
187
+ source = hf_source(type, model, revision, ref=ref, manifest=manifest, files=files,
188
188
  executables=executables, dry_run=dry_run)
189
189
  if source is not None:
190
190
  result = self.transport.request('POST', self._path(), source)
191
- return result if dry_run else self._native_result(result, kind)
192
- return self._publish(kind, ref, manifest, files, dry_run,
191
+ return result if dry_run else self._native_result(result, type)
192
+ return self._publish(type, ref, manifest, files, dry_run,
193
193
  executables=executables, idempotency_key=idempotency_key)
194
194
 
195
195
  def update(self, data_id: str, *, ref: str | None = None, manifest: dict[str, Any] | None = None, files: dict[str, Any] | None = None, model: str | None = None, revision: str | None = None,
196
- executables: Sequence[str] = (), kind: str | None = None, dry_run: bool = False, idempotency_key: str | None = None):
196
+ executables: Sequence[str] = (), type: str | None = None, dry_run: bool = False, idempotency_key: str | None = None):
197
197
  """Publish a new revision of the same asset, preserving its previous revision."""
198
198
  from envloop_data.data_publications import hf_source
199
199
 
200
- source = hf_source('checkpoint' if kind is None else kind, model, revision, ref=ref, manifest=manifest, files=files,
200
+ source = hf_source('checkpoint' if type is None else type, model, revision, ref=ref, manifest=manifest, files=files,
201
201
  executables=executables, dry_run=dry_run)
202
202
  resource = self.get(data_id)
203
- if kind is not None and kind != resource['kind']:
204
- raise ValueError('update must preserve the publication kind')
203
+ if type is not None and type != resource['type']:
204
+ raise ValueError('update must preserve the publication type')
205
205
  if source is not None:
206
- if (resource['kind'] != 'checkpoint' or resource.get('metadata_only') is not True
206
+ if (resource['type'] != 'checkpoint' or resource.get('metadata_only') is not True
207
207
  or resource.get('name') != model or not resource.get('model_uri')):
208
208
  raise ValueError('HF update must preserve the native model identity')
209
209
  result = self.transport.request('POST', self._path(data_id) + '/revisions', source)
210
- return result if dry_run else self._native_result(result, resource['kind'])
211
- return self._publish(resource['kind'], ref, manifest, files, dry_run,
210
+ return result if dry_run else self._native_result(result, resource['type'])
211
+ return self._publish(resource['type'], ref, manifest, files, dry_run,
212
212
  parent_id=data_id, executables=executables, idempotency_key=idempotency_key)
213
213
 
214
214
  def delete(self, data_id: str, *, dry_run: bool = False):
@@ -217,9 +217,9 @@ class Data:
217
217
  return self.transport.request('DELETE', self._path(data_id) + ('?dry_run=true' if dry_run else ''))
218
218
 
219
219
  @staticmethod
220
- def _native_result(result: Any, kind: str):
220
+ def _native_result(result: Any, data_type: str):
221
221
  resource = result.get('resource', result) if isinstance(result, dict) else None
222
- if (isinstance(resource, dict) and resource.get('kind') == kind
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
  and re.fullmatch(r'ans_[A-Za-z0-9_-]+', resource.get('data_id', resource.get('id')))):
@@ -231,37 +231,37 @@ class Data:
231
231
  error.staging_id = staging
232
232
  raise error
233
233
 
234
- def _publish(self, kind: str, ref: str | None, manifest: dict[str, Any] | None, files: dict[str, Any] | None, dry_run: bool, *, parent_id: str | None = None, executables: Sequence[str] = (), idempotency_key: str | None = None):
234
+ def _publish(self, data_type: str, ref: str | None, manifest: dict[str, Any] | None, files: dict[str, Any] | None, dry_run: bool, *, parent_id: str | None = None, executables: Sequence[str] = (), idempotency_key: str | None = None):
235
235
  from envloop_data.data_publications import (
236
236
  INLINE_BYTES,
237
- KINDS,
237
+ TYPES,
238
238
  executable_files,
239
239
  prepare,
240
240
  )
241
241
 
242
- if kind not in KINDS or not isinstance(ref, str) or not ref or type(dry_run) is not bool:
243
- raise ValueError('select a publication kind, immutable ANS ref and boolean dry_run')
244
- executables = executable_files(kind, manifest, executables)
242
+ if data_type not in TYPES or not isinstance(ref, str) or not ref or type(dry_run) is not bool:
243
+ raise ValueError('select a publication type, immutable ANS ref and boolean dry_run')
244
+ executables = executable_files(data_type, manifest, executables)
245
245
  with prepare(manifest, files) as (manifest, entries, payload, total, digest):
246
246
  if total <= INLINE_BYTES and len(entries) <= 100:
247
247
  encoded = {entry['path']: base64.b64encode(payload.read(cast(int, entry['size']))).decode() for entry in entries}
248
- body: dict[str, Any] = {'kind': kind, 'ref': ref, 'manifest': manifest, 'files': encoded, 'dry_run': dry_run}
248
+ body: dict[str, Any] = {'type': data_type, 'ref': ref, 'manifest': manifest, 'files': encoded, 'dry_run': dry_run}
249
249
  if executables:
250
250
  body['executables'] = executables
251
251
  path = self._path(parent_id) + '/revisions' if parent_id else self._path()
252
252
  result = self.transport.request('POST', path, body)
253
- return result if dry_run else self._native_result(result, kind)
253
+ return result if dry_run else self._native_result(result, data_type)
254
254
  request_key = uuid4().hex if idempotency_key is None else idempotency_key
255
255
  identifier = None
256
256
  try:
257
- upload = self.create_upload(kind=kind, ref=ref, manifest=manifest, payload_sha256=digest,
257
+ upload = self.create_upload(type=data_type, ref=ref, manifest=manifest, payload_sha256=digest,
258
258
  idempotency_key=request_key, parent_id=parent_id,
259
259
  executables=executables, dry_run=dry_run)
260
260
  if dry_run:
261
261
  return upload
262
262
  identifier = upload.get('upload_id')
263
263
  if upload.get('state') == 'committed':
264
- return self._native_result(upload, kind)
264
+ return self._native_result(upload, data_type)
265
265
  identifier = upload['upload_id']
266
266
  part_bytes = upload['limits']['part_bytes']
267
267
  if type(part_bytes) is not int or not 0 < part_bytes <= INLINE_BYTES:
@@ -278,7 +278,7 @@ class Data:
278
278
  self.upload_part(identifier, number, chunk)
279
279
  if existing:
280
280
  raise RemoteError('DATA_UPLOAD_CONFLICT')
281
- return self._native_result(self.complete_upload(identifier), kind)
281
+ return self._native_result(self.complete_upload(identifier), data_type)
282
282
  except BaseException as error:
283
283
  # Keep server state recoverable; do not silently abort another
284
284
  # process's resumable upload or lose its reservation identifier.
@@ -289,16 +289,16 @@ class Data:
289
289
  cast(Any, error).upload_id = identifier
290
290
  raise
291
291
 
292
- def create_upload(self, *, kind: str, ref: str | None, manifest: dict[str, Any] | None, payload_sha256: str, idempotency_key: str, parent_id: str | None = None,
292
+ def create_upload(self, *, type: str, ref: str | None, manifest: dict[str, Any] | None, payload_sha256: str, idempotency_key: str, parent_id: str | None = None,
293
293
  executables: Sequence[str] = (), dry_run: bool = False):
294
- from envloop_data.data_publications import KINDS, executable_files
294
+ from envloop_data.data_publications import TYPES, executable_files
295
295
 
296
- if (kind not in KINDS or not isinstance(ref, str) or not ref or type(dry_run) is not bool
296
+ if (type not in TYPES or not isinstance(ref, str) or not ref or not isinstance(dry_run, bool)
297
297
  or not isinstance(payload_sha256, str) or not re.fullmatch(r'[a-f0-9]{64}', payload_sha256)
298
298
  or not isinstance(idempotency_key, str) or not re.fullmatch(r'[A-Za-z0-9_.:-]{1,128}', idempotency_key)):
299
299
  raise ValueError('invalid upload metadata or idempotency key')
300
- executables = executable_files(kind, manifest, executables)
301
- body: dict[str, Any] = dict(kind=kind, ref=ref, manifest=manifest, payload_sha256=payload_sha256, dry_run=dry_run)
300
+ executables = executable_files(type, manifest, executables)
301
+ body: dict[str, Any] = dict(type=type, ref=ref, manifest=manifest, payload_sha256=payload_sha256, dry_run=dry_run)
302
302
  if executables:
303
303
  body['executables'] = executables
304
304
  if parent_id is not None:
@@ -10,7 +10,7 @@ from typing import Any
10
10
 
11
11
  from envloop_data.data_publications import (
12
12
  INLINE_BYTES,
13
- KINDS,
13
+ TYPES,
14
14
  open_file,
15
15
  prepare,
16
16
  storage_files,
@@ -20,7 +20,7 @@ from envloop_data.data_publications import (
20
20
  def add_commands(groups: "argparse._SubParsersAction[argparse.ArgumentParser]", listing: Callable[[Any], argparse.ArgumentParser]):
21
21
  data = groups.add_parser('data', help='Manage Project ANS resources and verified downloads').add_subparsers(dest='action', required=True)
22
22
  command = listing(data)
23
- command.add_argument('--type', required=True, choices=[*KINDS, 'artifact'])
23
+ command.add_argument('--type', required=True, choices=[*TYPES, 'artifact'])
24
24
  for option in ('ref', 'job-id', 'trial-id'):
25
25
  command.add_argument('--' + option)
26
26
  for action in ('get', 'files'):
@@ -33,7 +33,7 @@ def add_commands(groups: "argparse._SubParsersAction[argparse.ArgumentParser]",
33
33
  command.set_defaults(_list_view=True)
34
34
  for action in ('create', 'update'):
35
35
  command = data.add_parser(action, help='Register native content or HF metadata' if action == 'create' else 'Register a new immutable revision')
36
- command.add_argument('--type', required=action == 'create', choices=KINDS)
36
+ command.add_argument('--type', required=action == 'create', choices=TYPES)
37
37
  if action == 'update':
38
38
  command.add_argument('data_id', metavar='DATA_ID')
39
39
  publication_arguments(command, source=True)
@@ -51,7 +51,7 @@ def add_commands(groups: "argparse._SubParsersAction[argparse.ArgumentParser]",
51
51
  command.add_argument('--json', action='store_true')
52
52
  uploads = data.add_parser('upload', help='Explicit resumable upload lifecycle').add_subparsers(dest='upload_action', required=True)
53
53
  command = uploads.add_parser('create', help='Verify local input and reserve a multipart upload')
54
- command.add_argument('--type', choices=KINDS, required=True)
54
+ command.add_argument('--type', choices=TYPES, required=True)
55
55
  publication_arguments(command)
56
56
  command.add_argument('--idempotency-key', required=True)
57
57
  command.add_argument('--parent-id', help='Existing publication when creating a new revision')
@@ -128,7 +128,7 @@ def publication_input(args: argparse.Namespace):
128
128
 
129
129
  def execute(data: Any, args: argparse.Namespace):
130
130
  if args.action == 'list':
131
- return data.list(kind=args.type, ref=args.ref, job_id=args.job_id, trial_id=args.trial_id, limit=args.limit, cursor=args.cursor)
131
+ return data.list(type=args.type, ref=args.ref, job_id=args.job_id, trial_id=args.trial_id, limit=args.limit, cursor=args.cursor)
132
132
  if args.action == 'get':
133
133
  return {'resource': data.get(args.data_id), 'files': data.files(args.data_id, limit=args.limit, cursor=args.cursor)}
134
134
  if args.action == 'files':
@@ -148,8 +148,8 @@ def execute(data: Any, args: argparse.Namespace):
148
148
  manifest, files = publication_input(args)
149
149
  options.update(ref=args.ref, manifest=manifest, files=files, executables=args.executables)
150
150
  if args.action == 'create':
151
- return data.create(kind=args.type, **options)
152
- return data.update(args.data_id, kind=args.type, **options)
151
+ return data.create(type=args.type, **options)
152
+ return data.update(args.data_id, type=args.type, **options)
153
153
  if args.action == 'register':
154
154
  return register(data, args)
155
155
  if args.action == 'registration':
@@ -160,7 +160,7 @@ def execute(data: Any, args: argparse.Namespace):
160
160
  if args.upload_action == 'create':
161
161
  manifest, files = publication_input(args)
162
162
  with prepare(manifest, files) as (manifest, _, _, _, digest):
163
- return data.create_upload(kind=args.type, ref=args.ref, manifest=manifest, payload_sha256=digest,
163
+ return data.create_upload(type=args.type, ref=args.ref, manifest=manifest, payload_sha256=digest,
164
164
  idempotency_key=args.idempotency_key, parent_id=args.parent_id,
165
165
  executables=args.executables, dry_run=args.dry_run)
166
166
  if args.upload_action == 'part':
@@ -11,7 +11,7 @@ from contextlib import contextmanager
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
 
14
- KINDS = ('dataset', 'checkpoint', 'runner', 'recipe')
14
+ TYPES = ('dataset', 'checkpoint', 'runner', 'recipe')
15
15
  INLINE_BYTES = 8 * 1024 * 1024
16
16
  MAX_FILE = 512 * 1024 * 1024
17
17
  MAX_TOTAL = 1024 * 1024 * 1024
@@ -50,23 +50,23 @@ def storage_files(manifest: dict[str, Any]) -> list[dict[str, Any]]:
50
50
  return sorted(projected, key=lambda item: item['path'])
51
51
 
52
52
 
53
- def executable_files(kind: str, manifest: Any, executables: Sequence[str]) -> list[str]:
53
+ def executable_files(data_type: str, manifest: Any, executables: Sequence[str]) -> list[str]:
54
54
  """Explicit Runner inventory; do not modify its immutable native manifest."""
55
55
  if not isinstance(executables, (tuple, list)):
56
56
  raise ValueError('executables must be a sequence of declared Runner paths')
57
57
  entries = storage_files(manifest)
58
58
  paths = {entry['path'] for entry in entries}
59
59
  if (any(not isinstance(path, str) or path not in paths for path in executables)
60
- or len(set(executables)) != len(executables) or executables and kind != 'runner'):
60
+ or len(set(executables)) != len(executables) or executables and data_type != 'runner'):
61
61
  raise ValueError('executables must select distinct declared Runner files')
62
62
  return sorted(executables)
63
63
 
64
64
 
65
- def hf_source(kind: str, model: str | None, revision: str | None, *, ref: Any, manifest: Any, files: Any, executables: Any,
65
+ def hf_source(data_type: str, model: str | None, revision: str | None, *, ref: Any, manifest: Any, files: Any, executables: Any,
66
66
  dry_run: bool):
67
67
  if model is None and revision is None:
68
68
  return None
69
- if (kind != 'checkpoint' or ref is not None or manifest is not None or files is not None
69
+ if (data_type != 'checkpoint' or ref is not None or manifest is not None or files is not None
70
70
  or executables or type(dry_run) is not bool
71
71
  or not isinstance(model, str) or len(model) > 193
72
72
  or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,95}/[A-Za-z0-9][A-Za-z0-9._-]{0,95}', model)
@@ -74,7 +74,7 @@ def hf_source(kind: str, model: str | None, revision: str | None, *, ref: Any, m
74
74
  or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._/-]*', revision) or '..' in revision
75
75
  or any(part in ('', '.') for part in revision.split('/'))):
76
76
  raise ValueError('HF metadata requires checkpoint model and explicit revision, without content inputs')
77
- return {'kind': 'checkpoint', 'source': {'type': 'hf', 'model': model, 'revision': revision}, 'dry_run': dry_run}
77
+ return {'type': 'checkpoint', 'source': {'type': 'hf', 'model': model, 'revision': revision}, 'dry_run': dry_run}
78
78
 
79
79
 
80
80
  @contextmanager
@@ -234,7 +234,7 @@ class Registrations:
234
234
  if identity.get('name') != dataset or identity.get('version') != version:
235
235
  raise _invalid(f'dataset.toml declares {identity.get("name")}@{identity.get("version")}, not {dataset}@{version}')
236
236
  size, sha256 = _digest(bundle)
237
- body = {'kind': 'dataset', 'ref': f'envloop://datasets/{dataset}@{version}', 'payload_sha256': sha256,
237
+ body = {'type': 'dataset', 'ref': f'envloop://datasets/{dataset}@{version}', 'payload_sha256': sha256,
238
238
  'manifest': {'schema': 'envhub.artifact/v1', 'kind': 'file', 'metadata': BUNDLE,
239
239
  'files': [{'path': name, 'size': size, 'sha256': sha256}]}}
240
240
  workspace, project = self.data._scope()
@@ -62,7 +62,7 @@ class Datasets:
62
62
 
63
63
 
64
64
  def _checkpoint_matches(checkpoint: str, item: dict[str, Any]) -> bool:
65
- if checkpoint in {item.get('checkpoint_id'), item.get('uri'), item.get('model_uri')}:
65
+ if checkpoint in {item.get('resource_id'), item.get('checkpoint_id'), item.get('uri'), item.get('model_uri')}:
66
66
  return True
67
67
  parsed = urlsplit(checkpoint)
68
68
  if parsed.scheme not in {'envloop', 'tinker'} or parsed.query or parsed.fragment:
@@ -133,13 +133,13 @@ class Checkpoints:
133
133
  def delete(self, checkpoint_id: str) -> Any:
134
134
  return self.transport.request('DELETE', f"/v1/checkpoints/{quote(checkpoint_id, safe='')}")
135
135
 
136
- def download(self, checkpoint: str, directory: str | Path, *, job_id: str | None = None, run_id: str | None = None) -> dict[str, Any]:
136
+ def download(self, checkpoint: str, directory: str | Path, *, job_id: str | None = None, trial_id: str | None = None) -> dict[str, Any]:
137
137
  from pathlib import Path
138
138
 
139
139
  directory = Path(directory)
140
140
  matches, cursor, seen = cast(list[dict[str, Any]], []), None, set()
141
141
  while True:
142
- page = self.list(job_id=job_id, run_id=run_id, cursor=cursor, limit=100)
142
+ page = self.list(job_id=job_id, trial_id=trial_id, cursor=cursor, limit=100)
143
143
  for item in page['items']:
144
144
  if _checkpoint_matches(checkpoint, item):
145
145
  matches.append(item)
@@ -166,14 +166,16 @@ class Checkpoints:
166
166
  if urlsplit(item.get('uri', '')).scheme == 'mock' and item.get('validation_mode') == 'mock':
167
167
  return self._download_archive(item, files, directory)
168
168
  transport = self.transport
169
+ # The checkpoint ID (resource_id) and a uri are both accepted; sign with the reference the caller gave.
170
+ reference = {'resource_id': checkpoint} if checkpoint == item.get('resource_id') else {'uri': checkpoint}
169
171
 
170
172
  class Content:
171
173
  urls: dict[str, Any] | None = None
172
174
 
173
175
  def refresh(self) -> None:
174
176
  # Keep the original authorized identity and file hashes across refreshes.
175
- signed = transport.request('GET', query('/v1/checkpoints/download', uri=checkpoint,
176
- job_id=item['job_id'], run_id=run_id))
177
+ signed = transport.request('GET', query('/v1/checkpoints/download', **reference,
178
+ job_id=item['job_id'], trial_id=item.get('trial_id')))
177
179
  urls = signed.get('urls', {})
178
180
  if (set(names) != set(urls)
179
181
  or signed.get('checkpoint_id', item['checkpoint_id']) != item['checkpoint_id']):
@@ -47,12 +47,12 @@ def add_resource_commands(groups: "argparse._SubParsersAction[argparse.ArgumentP
47
47
  checkpoints = groups.add_parser('checkpoint', help='List and download saved training Checkpoints').add_subparsers(dest='action', required=True)
48
48
  command = listing(checkpoints)
49
49
  command.add_argument('--job-id', help='Only Checkpoints of this Train Job')
50
- command.add_argument('--run-id', help='Only Checkpoints of this training run label')
50
+ command.add_argument('--trial-id', help='Only Checkpoints of this Trial')
51
51
  download = checkpoints.add_parser('download', help='Download files and verify their sizes and SHA-256 hashes')
52
- download.add_argument('checkpoint', metavar='CHECKPOINT', help='Checkpoint ID or uri from `checkpoint list`')
52
+ download.add_argument('checkpoint', metavar='CHECKPOINT', help='Checkpoint ID (cp_…) or uri from `checkpoint list`')
53
53
  download.add_argument('directory', metavar='DIRECTORY', type=Path)
54
54
  download.add_argument('--job-id')
55
- download.add_argument('--run-id')
55
+ download.add_argument('--trial-id')
56
56
  from envloop.lifecycle_cli import wait_options
57
57
  delete = checkpoints.add_parser('delete', help='Remove an unreferenced Checkpoint and stored files')
58
58
  delete.add_argument('identifier', metavar='CHECKPOINT_ID')
@@ -83,8 +83,8 @@ def execute(args: argparse.Namespace, client: Any):
83
83
  result = client.checkpoints.delete(args.identifier)
84
84
  return client.operations.wait(result['operation_id'], timeout=args.timeout_seconds) if args.wait else result
85
85
  if action == 'download':
86
- return client.checkpoints.download(args.checkpoint, args.directory, job_id=args.job_id, run_id=args.run_id)
87
- return client.checkpoints.list(job_id=args.job_id, run_id=args.run_id,
86
+ return client.checkpoints.download(args.checkpoint, args.directory, job_id=args.job_id, trial_id=args.trial_id)
87
+ return client.checkpoints.list(job_id=args.job_id, trial_id=args.trial_id,
88
88
  cursor=args.cursor, limit=args.limit)
89
89
 
90
90
 
@@ -125,9 +125,11 @@ def add_commands(groups: "argparse._SubParsersAction[argparse.ArgumentParser]"):
125
125
  help="Dataset name; optional author version (legacy positional form)",
126
126
  )
127
127
  job_submit.add_argument("--dataset", dest="dataset_option", type=dataset_reference, metavar="NAME[@VERSION]",
128
- help="Dataset name; default selection uses the platform's pinned snapshot")
128
+ help="Agent: Dataset name; default selection uses the platform's pinned snapshot. "
129
+ "Train recipe (--spec): record Dataset NAMESPACE/NAME@VERSION read in place of records")
129
130
  job_submit.add_argument("--config", help="Agent: Dataset subset; Train recipe: named preset; omitted preserves spec or server default")
130
- job_submit.add_argument("--split", help="one complete split name; omitted selects all splits")
131
+ job_submit.add_argument("--split", help="Agent: one complete split name, omitted selects all splits; "
132
+ "Train recipe: the record Dataset split to train on (required with --dataset)")
131
133
  job_submit.add_argument("--revision", help="Git branch/tag/commit; must match the platform's pinned snapshot")
132
134
  runner_arguments(job_submit)
133
135
  job_submit.add_argument("--attempts", type=int)
@@ -141,8 +143,8 @@ def add_commands(groups: "argparse._SubParsersAction[argparse.ArgumentParser]"):
141
143
  job_submit.add_argument("--timeout-seconds", type=wait_seconds,
142
144
  help="Agent/Sandbox or Train Runner execution timeout (whole seconds); Train recipe --wait timeout (default: 600)")
143
145
  job_submit.add_argument("--max-cost-usd", type=max_cost_usd, metavar="USD",
144
- help="Agent/Sandbox: Job spend cap in USD (model calls plus Sandbox time); the Job stops "
145
- "with error BUDGET_EXCEEDED once it is reached")
146
+ help="Agent/Sandbox: Job spend cap in USD (model calls plus Sandbox time); Train recipe "
147
+ "--spec: cap on the GPU time of all Trials. Work stops with error BUDGET_EXCEEDED")
146
148
  job_submit.add_argument("--delete-delay-seconds", type=nonnegative_seconds, metavar="SECONDS",
147
149
  help="keep each Sandbox (E2B or Fly) after completion for SECONDS (default: 0); cancellation/timeout delete immediately")
148
150
  job_submit.add_argument("--wait", action="store_true")
@@ -197,6 +199,12 @@ def add_commands(groups: "argparse._SubParsersAction[argparse.ArgumentParser]"):
197
199
  train_log.add_argument("--limit", type=log_page_size, default=200, help="Maximum records per page (default: 200)")
198
200
  train_log.add_argument("--follow", action="store_true", help="Drain pages and poll until collection ends; Ctrl-C exits without stopping the Job")
199
201
  train_log.add_argument("--json", action="store_true", help="Output the complete page as JSON; --follow emits one page per line (NDJSON)")
202
+ train_metrics = jobs.add_parser("metrics", help="Read or follow a Train Job's per-step metrics")
203
+ train_metrics.add_argument("job_id", metavar="JOB_ID")
204
+ train_metrics.add_argument("--follow", action="store_true",
205
+ help="Print new steps until the Job ends; Ctrl-C exits without stopping the Job")
206
+ train_metrics.add_argument("--json", action="store_true",
207
+ help="Output the complete response as JSON; --follow emits one step per line (NDJSON)")
200
208
  from envloop._optional import available
201
209
  if available('rsi-loop'):
202
210
  from envloop_rsi_loop.cli import add_commands as add_runs
@@ -409,6 +417,12 @@ def execute_job(args: argparse.Namespace, client: Any, cli: argparse.ArgumentPar
409
417
  from envloop_experiment.train_log_cli import read_logs
410
418
 
411
419
  raise CommandExit(read_logs(client.jobs, args))
420
+ elif args.action == "metrics":
421
+ from envloop_experiment.train_metrics_cli import read_metrics
422
+
423
+ result = read_metrics(client.training_jobs, args)
424
+ if result is None:
425
+ raise CommandExit(0)
412
426
  elif args.action == "run" and args.run_action in {"start", "live", "steer", "stop"}:
413
427
  from envloop_rsi_loop.rsi_cli import run_rsi
414
428
 
@@ -426,12 +440,13 @@ def execute_job(args: argparse.Namespace, client: Any, cli: argparse.ArgumentPar
426
440
  else:
427
441
  result = getattr(client.jobs, 'status' if args.action == 'get' else args.action)(args.job_id)
428
442
  if args.action == 'get':
443
+ from envloop_experiment.train_metrics_cli import availability_line
429
444
  from envloop_experiment.train_progress import job_progress, progress_line
430
445
 
431
446
  # The JSON on stdout is unchanged; a Train Job's live progress is a readable stderr line.
432
- line = progress_line(job_progress(result))
433
- if line:
434
- print(line, file=sys.stderr)
447
+ for line in (progress_line(job_progress(result)), availability_line(result)):
448
+ if line:
449
+ print(line, file=sys.stderr)
435
450
  if args.action in {'get', 'result'}:
436
451
  from envloop_experiment.job_cost import cost_line
437
452
 
@@ -441,10 +456,27 @@ def execute_job(args: argparse.Namespace, client: Any, cli: argparse.ArgumentPar
441
456
  print(line, file=sys.stderr)
442
457
  from envloop.cli_errors import failure_lines
443
458
 
444
- for line in failure_lines(result):
459
+ for line in failure_lines(train_trials(client, result)):
445
460
  print(line, file=sys.stderr)
446
461
  return result
447
462
 
463
+
464
+ def train_trials(client: Any, result: Any):
465
+ """The failed Trials of a failed Train Job as a members view; any other Job is returned unchanged.
466
+
467
+ A Train Job lists only its Trial IDs, so their codes come from one Trial list read. A failed read
468
+ keeps the Job output and exit status: the failure lines are a convenience, not the result.
469
+ """
470
+ if not isinstance(result, dict) or result.get('type') != 'train' or result.get('status') != 'failed':
471
+ return result
472
+ from envloop.http import RemoteError
473
+ try:
474
+ page = client.trials.list(type='train', job_id=result.get('job_id'), limit=100)
475
+ except (RemoteError, OSError):
476
+ return result
477
+ attempts = [trial for trial in page.get('items') or [] if isinstance(trial, dict)] if isinstance(page, dict) else []
478
+ return {'members': [{'attempts': attempts}]}
479
+
448
480
  def execute_trial(args: argparse.Namespace, client: Any, cli: argparse.ArgumentParser):
449
481
  if args.action == "artifact":
450
482
  if args.artifact_action == "download":
@@ -1,4 +1,4 @@
1
- """One-line view of an Agent/Sandbox Job's ``cost`` and budget; reading never changes the Job."""
1
+ """One-line view of a Job's ``cost`` and budget; reading never changes the Job."""
2
2
 
3
3
  import math
4
4
  from typing import Any
@@ -17,18 +17,21 @@ def _usd(value: object) -> str:
17
17
  def cost_line(job: Any) -> str | None:
18
18
  """``Cost (estimate): $5.03 (model $4.12, sandbox $0.91) of a $5.00 budget; stopped: budget reached``.
19
19
 
20
- None when the response has no ``cost`` block (Train and Inference Jobs, older platforms). An unknown
21
- part is shown as unknown, never as zero.
20
+ A budgeted Train Job reports GPU time instead: ``Cost (estimate): $6.79 (GPU) of a $10.00 budget, $3.21 left``.
21
+ None when the response has no ``cost`` block (Inference Jobs, Train Jobs without a budget, older platforms).
22
+ An unknown part is shown as unknown, never as zero.
22
23
  """
23
24
  cost = job.get("cost") if isinstance(job, dict) else None
24
25
  if not isinstance(cost, dict):
25
26
  return None
26
- line = f"Cost (estimate): {_usd(cost.get('total_usd'))} (model {_usd(cost.get('model_usd'))}, " \
27
- f"sandbox {_usd(cost.get('sandbox_usd'))})"
27
+ train = "gpu_usd" in cost
28
+ line = f"Cost (estimate): {_usd(cost.get('total_usd'))} " + (
29
+ "(GPU)" if train else f"(model {_usd(cost.get('model_usd'))}, sandbox {_usd(cost.get('sandbox_usd'))})")
28
30
  if cost.get("max_cost_usd") is not None:
29
31
  line += f" of a {_usd(cost.get('max_cost_usd'))} budget"
30
32
  if cost.get("unmetered") is True:
31
- line += "; some usage has no price, so the budget cannot be enforced"
33
+ line += ("; the GPUs have no price, so the budget cannot be enforced" if train
34
+ else "; some usage has no price, so the budget cannot be enforced")
32
35
  elif cost.get("budget_exceeded") is True:
33
36
  line += "; stopped: budget reached"
34
37
  else:
@@ -37,5 +40,5 @@ def cost_line(job: Any) -> str | None:
37
40
  if isinstance(reserved, (int, float)) and not isinstance(reserved, bool) and reserved > 0:
38
41
  line += f" ({_usd(reserved)} reserved by running Trials)"
39
42
  if cost.get("billing_mode") in {"off", "shadow"}:
40
- line += f"; sandbox time not charged ({cost['billing_mode']} billing)"
43
+ line += f"; {'GPU' if train else 'sandbox'} time not charged ({cost['billing_mode']} billing)"
41
44
  return line
@@ -14,6 +14,30 @@ class SpecFileError(ValueError):
14
14
  """The input file is not a Job JSON object."""
15
15
 
16
16
 
17
+ def train_dataset(spec: dict[str, Any], dataset: str | None, split: str | None) -> dict[str, Any]:
18
+ """A Train spec's record Dataset selector: NAME@VERSION and --split replace or complete the file's."""
19
+ if 'runner' in spec:
20
+ raise ValueError('--dataset and --split select recipe training data; a Runner Train spec takes no records')
21
+ if 'records' in spec:
22
+ raise ValueError('--dataset replaces inline records; remove records from the Train spec')
23
+ current = spec.get('dataset', {})
24
+ if not isinstance(current, dict):
25
+ raise ValueError('Train spec dataset must be an object')
26
+ selector = dict(current)
27
+ if dataset is not None:
28
+ name, separator, version = dataset.rpartition('@')
29
+ if not separator:
30
+ raise ValueError('Train --dataset needs an exact NAMESPACE/NAME@VERSION')
31
+ if (name, version) != (current.get('name'), current.get('version')):
32
+ selector.pop('manifest_sha256', None) # a pin belongs to the version it was read from
33
+ selector.update(name=name, version=version)
34
+ if split is not None:
35
+ selector['split'] = split
36
+ if not {'name', 'version', 'split'} <= set(selector):
37
+ raise ValueError('Train --dataset needs NAMESPACE/NAME@VERSION and --split')
38
+ return selector
39
+
40
+
17
41
  def spec_request(args: argparse.Namespace):
18
42
  if args.trials is not None:
19
43
  raise ValueError('--trials cannot be combined with --spec; the file defines its Trial list')
@@ -42,10 +66,11 @@ def spec_request(args: argparse.Namespace):
42
66
  def supplied(names: Iterable[str]):
43
67
  return [name for name in names if getattr(args, name) is not None]
44
68
  if kind in {'train', 'inference'}:
45
- invalid = supplied(('dataset', 'dataset_option', 'source', 'task_id', 'split', 'revision',
46
- 'job_id', 'tag', 'attempts', 'profile', 'delete_delay_seconds', 'max_cost_usd'))
69
+ invalid = supplied(('source', 'task_id', 'revision',
70
+ 'job_id', 'tag', 'attempts', 'profile', 'delete_delay_seconds'))
47
71
  if kind == 'inference':
48
- invalid += supplied(('config', 'priority', 'seed', 'command', 'image', 'timeout_seconds', *SHORTCUT_FIELDS))
72
+ invalid += supplied(('dataset', 'dataset_option', 'split', 'config', 'priority', 'seed', 'command', 'image',
73
+ 'timeout_seconds', 'max_cost_usd', *SHORTCUT_FIELDS))
49
74
  if args.wait:
50
75
  invalid.append('wait')
51
76
  if invalid:
@@ -62,12 +87,18 @@ def spec_request(args: argparse.Namespace):
62
87
  raise ValueError('Train Job spec requires non-empty trials with spec objects')
63
88
  if args.priority is not None:
64
89
  request['priority'] = args.priority
90
+ if args.max_cost_usd is not None:
91
+ # One cap on the GPU time of all the Job's Trials; the platform refuses it where it cannot enforce it.
92
+ request['budget'] = {'max_cost_usd': args.max_cost_usd}
65
93
  runner_fields = ('runner', 'command', 'image', 'timeout_seconds', *SHORTCUT_FIELDS)
94
+ dataset = args.dataset if args.dataset is not None else args.dataset_option
66
95
  for trial in trials:
67
96
  spec = trial['spec']
68
97
  for field in ('seed', 'config'):
69
98
  if getattr(args, field) is not None:
70
99
  spec[field] = getattr(args, field)
100
+ if dataset is not None or args.split is not None:
101
+ spec['dataset'] = train_dataset(spec, dataset, args.split)
71
102
  active_fields = runner_fields
72
103
  values = vars(args).copy()
73
104
  if not (args.runner or spec.get('runner')) and args.wait:
@@ -62,6 +62,11 @@ class TrainJobs:
62
62
  query = urlencode(log_query(source=source, cursor=cursor, tail=tail, limit=limit, process=process))
63
63
  return self.transport.request("GET", f"/v1/jobs/{quote(job_id, safe='')}/logs?{query}")
64
64
 
65
+ def metrics(self, job_id: str) -> dict[str, Any]:
66
+ """Recorded training metrics; ``loss.per_step`` holds one point per step the trainer reported
67
+ (loss, learning_rate, grad_norm, epoch, tokens_seen, seconds_per_step, observation time ``at``)."""
68
+ return self.transport.request("GET", f"/v1/jobs/{quote(job_id, safe='')}/metrics")
69
+
65
70
  def stop(self, job_id: str) -> dict[str, Any]:
66
71
  return self._view(self.transport.request('POST', f'/v1/jobs/{quote(job_id, safe="")}/stop'))
67
72
 
@@ -72,9 +77,9 @@ class TrainJobs:
72
77
  page = self.transport.request("GET", "/v1/jobs?" + query)
73
78
  return {**page, "items": [self._view(item) for item in page["items"]]}
74
79
 
75
- def checkpoints(self, *, job_id: str | None = None, run_id: str | None = None, cursor: str | None = None, limit: int = 25) -> dict[str, Any]:
80
+ def checkpoints(self, *, job_id: str | None = None, trial_id: str | None = None, cursor: str | None = None, limit: int = 25) -> dict[str, Any]:
76
81
  query = urlencode({key: value for key, value in {
77
- "job_id": job_id, "run_id": run_id,
82
+ "job_id": job_id, "trial_id": trial_id,
78
83
  "cursor": cursor, "limit": limit,
79
84
  }.items() if value is not None})
80
85
  return self.transport.request("GET", "/v1/checkpoints?" + query)
@@ -12,7 +12,7 @@ from envloop.http import TRANSIENT_CODES, RemoteError
12
12
  from envloop.terminal import color_enabled, compact_time, safe_text, status_text, style
13
13
  from envloop_experiment.train_progress import job_progress, progress_line
14
14
 
15
- # While following a Train Job, re-read its progress at most this often (the platform observes it about every 30 s).
15
+ # While following a Train Job, re-read its progress at most this often (the platform observes it about every 10 s).
16
16
  PROGRESS_POLL_SEC = 30
17
17
  # Consecutive transient failures `job log --follow` retries before it exits with the error.
18
18
  FOLLOW_RETRIES = 3
@@ -0,0 +1,145 @@
1
+ """Per-step Train Job metrics as a table or JSON; reading never changes the remote Job."""
2
+
3
+ import argparse
4
+ import json
5
+ import math
6
+ import re
7
+ import sys
8
+ import time
9
+ from datetime import datetime
10
+ from typing import Any
11
+
12
+ from envloop.cli_output import display, table
13
+ from envloop.http import TRANSIENT_CODES, RemoteError
14
+
15
+ # The platform records new steps about every 10 s.
16
+ POLL_SEC = 10
17
+ # After the Job ends, its last steps are recorded by one final read on the platform.
18
+ FINAL_READ_DELAY_SEC = 5
19
+ FOLLOW_RETRIES = 3
20
+ TERMINAL = {"completed", "succeeded", "failed", "cancelled", "SUCCEEDED", "FAILED", "CANCELLED"}
21
+ HEADERS = ("STEP", "LOSS", "LEARNING RATE", "GRAD NORM", "EPOCH", "TOKENS", "SECONDS/STEP", "OBSERVED AT")
22
+ # Column widths of --follow, which prints rows before later ones are known.
23
+ WIDTHS = (6, 10, 13, 10, 8, 12, 12, 19)
24
+
25
+
26
+ def _number(value: Any) -> float | None:
27
+ return value if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) else None
28
+
29
+
30
+ def job_steps(metrics: Any) -> list[dict[str, Any]]:
31
+ """``loss.per_step`` of a metrics response: one point per recorded step."""
32
+ loss = metrics.get("loss") if isinstance(metrics, dict) else None
33
+ points = loss.get("per_step") if isinstance(loss, dict) else None
34
+ return [point for point in points if isinstance(point, dict) and _number(point.get("step")) is not None] \
35
+ if isinstance(points, list) else []
36
+
37
+
38
+ def _value(value: Any, spec: str) -> str | None:
39
+ number = _number(value)
40
+ return None if number is None else format(int(number) if spec == "d" else number, spec)
41
+
42
+
43
+ def _observed(value: Any) -> str | None:
44
+ number = _number(value)
45
+ return None if number is None else datetime.fromtimestamp(number).strftime("%Y-%m-%d %H:%M:%S")
46
+
47
+
48
+ def step_row(point: dict[str, Any], *, model: bool = False) -> tuple[object, ...]:
49
+ row = (_value(point.get("step"), "d"), _value(point.get("loss"), ".6g"),
50
+ _value(point.get("learning_rate"), ".4g"), _value(point.get("grad_norm"), ".4g"),
51
+ _value(point.get("epoch"), ".4g"), _value(point.get("tokens_seen"), "d"),
52
+ _value(point.get("seconds_per_step"), ".3g"), _observed(point.get("at")))
53
+ return (point.get("model_id"), *row) if model else row
54
+
55
+
56
+ def _fixed(cells: tuple[object, ...]) -> str:
57
+ return " ".join(display(cell).ljust(width) for cell, width in zip(cells, WIDTHS)).rstrip()
58
+
59
+
60
+ def _read(read: Any, args: argparse.Namespace, retries: list[int]) -> Any:
61
+ """One read; ``--follow`` retries transient failures and names each retry."""
62
+ while True:
63
+ try:
64
+ value = read()
65
+ except RemoteError as error:
66
+ transient = error.code in TRANSIENT_CODES or error.status_code in {429, 500, 502, 503, 504}
67
+ if not args.follow or not transient or retries[0] >= FOLLOW_RETRIES:
68
+ raise
69
+ retries[0] += 1
70
+ delay = error.retry_after if error.retry_after is not None else 2
71
+ code = error.code if re.fullmatch(r"[A-Z0-9_]{1,128}", error.code) else "HTTP_ERROR"
72
+ status = f", HTTP {error.status_code}" if isinstance(error.status_code, int) else ""
73
+ print(f"Metrics read failed ({code}{status}); retry {retries[0]}/{FOLLOW_RETRIES} in {delay:g}s.",
74
+ file=sys.stderr, flush=True)
75
+ time.sleep(delay)
76
+ continue
77
+ retries[0] = 0
78
+ return value
79
+
80
+
81
+ def read_metrics(train: Any, args: argparse.Namespace) -> Any:
82
+ """``job metrics``: the response for ``--json``; otherwise prints and returns None."""
83
+ if not args.follow:
84
+ metrics = train.metrics(args.job_id)
85
+ if args.json:
86
+ return metrics
87
+ steps = job_steps(metrics)
88
+ if not steps:
89
+ print("No step metrics recorded yet." if not metrics.get("available") else "No step metrics.",
90
+ file=sys.stderr)
91
+ return None
92
+ models = len({point.get("model_id") for point in steps}) > 1
93
+ print(table(("MODEL", *HEADERS) if models else HEADERS, [step_row(point, model=models) for point in steps]))
94
+ return None
95
+ return follow(train, args)
96
+
97
+
98
+ def follow(train: Any, args: argparse.Namespace) -> None:
99
+ seen: set[tuple[Any, Any]] = set()
100
+ retries = [0]
101
+ header = False
102
+ model = None # the Trials of a group Job run one after another; each starts with its model
103
+ final = False
104
+ try:
105
+ while True:
106
+ job = _read(lambda: train.status(args.job_id), args, retries)
107
+ metrics = _read(lambda: train.metrics(args.job_id), args, retries)
108
+ for point in job_steps(metrics):
109
+ identity = (point.get("model_id"), point.get("step"))
110
+ if identity in seen:
111
+ continue
112
+ seen.add(identity)
113
+ if args.json:
114
+ # One step per line; JSON escaping keeps control sequences out of the terminal.
115
+ print(json.dumps(point, ensure_ascii=True), flush=True)
116
+ continue
117
+ if isinstance(point.get("model_id"), str) and point["model_id"] != model:
118
+ model = point["model_id"]
119
+ print(f"Model {display(model)}", flush=True)
120
+ header = False
121
+ if not header:
122
+ print(_fixed(HEADERS), flush=True)
123
+ header = True
124
+ print(_fixed(step_row(point)), flush=True)
125
+ if final:
126
+ return None
127
+ if isinstance(job, dict) and job.get("status") in TERMINAL:
128
+ final = True # one more read picks up the steps recorded when the Job ended
129
+ time.sleep(FINAL_READ_DELAY_SEC)
130
+ continue
131
+ time.sleep(POLL_SEC)
132
+ except KeyboardInterrupt:
133
+ from envloop._optional import CommandExit
134
+
135
+ raise CommandExit(130) from None
136
+
137
+
138
+ def availability_line(job: Any) -> str | None:
139
+ """``Step metrics: ...`` for a Train Job response; None for other Jobs."""
140
+ training = job.get("training") if isinstance(job, dict) else None
141
+ if not isinstance(training, dict) or not isinstance(training.get("metrics_available"), bool):
142
+ return None
143
+ if training["metrics_available"]:
144
+ return f"Step metrics: available; read them with: el job metrics {display(job.get('job_id'))}"
145
+ return "Step metrics: not recorded yet"
@@ -48,5 +48,5 @@ def progress_line(progress: dict[str, Any] | None) -> str | None:
48
48
  if _number(progress.get("eta_seconds")) is not None:
49
49
  parts.append(f"ETA {_duration(progress['eta_seconds'])}")
50
50
  if isinstance(progress.get("updated_at"), str):
51
- parts.append(f"updated {safe_text(compact_time(progress['updated_at']))}")
51
+ parts.append(f"updated {safe_text(compact_time(progress['updated_at'], utc=True))}")
52
52
  return "Training progress: " + ", ".join(parts)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "envloop-cli"
3
- version = "1.28.0"
3
+ version = "1.29.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"
File without changes