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.
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/PKG-INFO +1 -1
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/_optional.py +1 -1
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/cli_errors.py +1 -1
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/cli_output.py +2 -1
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/terminal.py +8 -5
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/cli_output.py +5 -4
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/client.py +2 -2
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/data.py +35 -35
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/data_cli.py +8 -8
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/data_publications.py +6 -6
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/data_upload.py +1 -1
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/navigation_resources.py +7 -5
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/resource_cli.py +5 -5
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/cli.py +40 -8
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/job_cost.py +10 -7
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/job_spec_cli.py +34 -3
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/train.py +7 -2
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/train_log_cli.py +1 -1
- envloop_cli-1.29.0/packages/experiment/envloop_experiment/train_metrics_cli.py +145 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/train_progress.py +1 -1
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/pyproject.toml +1 -1
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/.gitignore +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/README.pypi.md +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/__init__.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/auth_cli.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/auth_login.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/auth_workspace.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/cli.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/client.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/config.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/downloads.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/http.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/lifecycle_cli.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/navigation_resources.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/profile_secrets.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/resource_cli.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/resources.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/utils/__init__.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/envloop/utils/logging.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/__init__.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/cli.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/http.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/profile_updates.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/recipes.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/runner_packages.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/__init__.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/cli_output.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/client.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/decision.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/http.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/inference.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/job_logs.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/navigation_resources.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/resource_cli.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/resources.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/runtime_params.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/sandbox.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/train_runner_cli.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/training/__init__.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/rsi-loop/envloop_rsi_loop/__init__.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/rsi-loop/envloop_rsi_loop/agent_runs.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/rsi-loop/envloop_rsi_loop/cli.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/rsi-loop/envloop_rsi_loop/cli_output.py +0 -0
- {envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/rsi-loop/envloop_rsi_loop/rsi_cli.py +0 -0
|
@@ -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
|
|
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
|
-
|
|
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, *,
|
|
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
|
|
58
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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', '
|
|
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(
|
|
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(
|
|
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, *,
|
|
118
|
-
if
|
|
119
|
-
raise ValueError('select a Data
|
|
120
|
-
return self.transport.request('GET', self._query(self._path(),
|
|
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
|
-
|
|
134
|
+
data_types = {'datasets':'dataset', 'artifacts':'artifact', 'model':'checkpoint',
|
|
135
135
|
'runners':'runner', 'recipes':'recipe'}
|
|
136
|
-
if reference.kind not in
|
|
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 = {'
|
|
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, *,
|
|
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(
|
|
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,
|
|
192
|
-
return self._publish(
|
|
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] = (),
|
|
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
|
|
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
|
|
204
|
-
raise ValueError('update must preserve the publication
|
|
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['
|
|
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['
|
|
211
|
-
return self._publish(resource['
|
|
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,
|
|
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('
|
|
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,
|
|
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
|
-
|
|
237
|
+
TYPES,
|
|
238
238
|
executable_files,
|
|
239
239
|
prepare,
|
|
240
240
|
)
|
|
241
241
|
|
|
242
|
-
if
|
|
243
|
-
raise ValueError('select a publication
|
|
244
|
-
executables = executable_files(
|
|
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] = {'
|
|
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,
|
|
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(
|
|
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,
|
|
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),
|
|
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, *,
|
|
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
|
|
294
|
+
from envloop_data.data_publications import TYPES, executable_files
|
|
295
295
|
|
|
296
|
-
if (
|
|
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(
|
|
301
|
-
body: dict[str, Any] = dict(
|
|
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
|
-
|
|
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=[*
|
|
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=
|
|
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=
|
|
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(
|
|
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(
|
|
152
|
-
return data.update(args.data_id,
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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 (
|
|
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 {'
|
|
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 = {'
|
|
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()
|
{envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/data/envloop_data/navigation_resources.py
RENAMED
|
@@ -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,
|
|
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,
|
|
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',
|
|
176
|
-
job_id=item['job_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('--
|
|
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('--
|
|
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,
|
|
87
|
-
return client.checkpoints.list(job_id=args.job_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
|
|
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);
|
|
145
|
-
"
|
|
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
|
|
433
|
-
|
|
434
|
-
|
|
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":
|
{envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/job_cost.py
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""One-line view of
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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 += ";
|
|
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
|
{envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/job_spec_cli.py
RENAMED
|
@@ -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(('
|
|
46
|
-
'job_id', 'tag', 'attempts', 'profile', 'delete_delay_seconds'
|
|
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',
|
|
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,
|
|
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, "
|
|
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)
|
{envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/train_log_cli.py
RENAMED
|
@@ -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
|
|
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"
|
{envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/train_progress.py
RENAMED
|
@@ -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)
|
|
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.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/__init__.py
RENAMED
|
File without changes
|
{envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/cli_output.py
RENAMED
|
File without changes
|
|
File without changes
|
{envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/decision.py
RENAMED
|
File without changes
|
|
File without changes
|
{envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/inference.py
RENAMED
|
File without changes
|
{envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/job_logs.py
RENAMED
|
File without changes
|
|
File without changes
|
{envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/resource_cli.py
RENAMED
|
File without changes
|
{envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/resources.py
RENAMED
|
File without changes
|
{envloop_cli-1.28.0 → envloop_cli-1.29.0}/packages/experiment/envloop_experiment/runtime_params.py
RENAMED
|
File without changes
|
|
File without changes
|
{envloop_cli-1.28.0 → envloop_cli-1.29.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
|