envloop-cli-experiment 1.23.0__py3-none-any.whl
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_experiment-1.23.0.dist-info/METADATA +11 -0
- envloop_cli_experiment-1.23.0.dist-info/RECORD +23 -0
- envloop_cli_experiment-1.23.0.dist-info/WHEEL +4 -0
- envloop_experiment/__init__.py +1 -0
- envloop_experiment/cli.py +467 -0
- envloop_experiment/cli_output.py +50 -0
- envloop_experiment/client.py +471 -0
- envloop_experiment/decision.py +20 -0
- envloop_experiment/http.py +142 -0
- envloop_experiment/inference.py +36 -0
- envloop_experiment/job_cost.py +41 -0
- envloop_experiment/job_logs.py +21 -0
- envloop_experiment/job_spec_cli.py +146 -0
- envloop_experiment/navigation_resources.py +43 -0
- envloop_experiment/resource_cli.py +33 -0
- envloop_experiment/resources.py +152 -0
- envloop_experiment/runtime_params.py +83 -0
- envloop_experiment/sandbox.py +28 -0
- envloop_experiment/train.py +93 -0
- envloop_experiment/train_log_cli.py +130 -0
- envloop_experiment/train_progress.py +52 -0
- envloop_experiment/train_runner_cli.py +66 -0
- envloop_experiment/training/__init__.py +4 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Job log rendering and bounded polling; reading never changes the remote Job."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from collections import OrderedDict
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from envloop.http import TRANSIENT_CODES, RemoteError
|
|
11
|
+
from envloop.terminal import color_enabled, compact_time, safe_text, status_text, style
|
|
12
|
+
from envloop_experiment.train_progress import job_progress, progress_line
|
|
13
|
+
|
|
14
|
+
# While following a Train Job, re-read its progress at most this often (the platform observes it about every 30 s).
|
|
15
|
+
PROGRESS_POLL_SEC = 30
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def log_line(item: dict[str, Any], *, color: bool = False):
|
|
19
|
+
timestamp = compact_time(safe_text(item.get("at") or ""))
|
|
20
|
+
level = safe_text(item.get("level") or "info")
|
|
21
|
+
message = safe_text(item["content"])
|
|
22
|
+
# Captured lines keep their own line break; print adds one, so drop it rather than double-space.
|
|
23
|
+
if message.endswith("\n"):
|
|
24
|
+
message = message[:-1]
|
|
25
|
+
if item.get("code"):
|
|
26
|
+
diagnostic = safe_text(item["code"])
|
|
27
|
+
if item.get("phase"):
|
|
28
|
+
diagnostic += f"; phase={safe_text(item['phase'])}"
|
|
29
|
+
message = f"[{diagnostic}] {message}"
|
|
30
|
+
if color:
|
|
31
|
+
timestamp = style(timestamp, "2") if timestamp else ""
|
|
32
|
+
level = status_text(level)
|
|
33
|
+
prefix = " ".join(part for part in (timestamp, level) if part)
|
|
34
|
+
return f"{prefix} {message}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def capture_summary(page: dict[str, Any]):
|
|
38
|
+
details = []
|
|
39
|
+
status = page.get("collection_status")
|
|
40
|
+
if status in {"partial", "failed", "unavailable"}:
|
|
41
|
+
details.append(f"collection={status}")
|
|
42
|
+
if page.get("truncated"):
|
|
43
|
+
details.append(f"truncated; dropped_bytes={page.get('dropped_bytes', 0)}")
|
|
44
|
+
source_reasons = set()
|
|
45
|
+
for source in page.get("sources", []):
|
|
46
|
+
if source.get("missing_reason"):
|
|
47
|
+
source_reasons.add(source["missing_reason"])
|
|
48
|
+
name = safe_text(source.get("name", "source"))
|
|
49
|
+
if source.get("process"):
|
|
50
|
+
name += f" ({safe_text(source['process'])})"
|
|
51
|
+
details.append(f"{name}: {safe_text(source['missing_reason'])}")
|
|
52
|
+
if page.get("missing_reason") and page["missing_reason"] not in source_reasons:
|
|
53
|
+
details.append(safe_text(page["missing_reason"]))
|
|
54
|
+
if page.get("has_more"):
|
|
55
|
+
details.append("more records available")
|
|
56
|
+
return "Log collection: " + "; ".join(details) if details else ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def show_progress(train: Any, job_id: str, state: dict[str, Any]):
|
|
60
|
+
"""Print a Train Job's progress line to stderr when it changes; other Job types stop the polling."""
|
|
61
|
+
state["next"] = time.monotonic() + PROGRESS_POLL_SEC
|
|
62
|
+
try:
|
|
63
|
+
job = train.status(job_id)
|
|
64
|
+
except RemoteError:
|
|
65
|
+
return # Progress is an observation; a failed read never interrupts the log stream.
|
|
66
|
+
if not isinstance(job, dict) or job.get("type") not in {None, "train"}:
|
|
67
|
+
state["enabled"] = False
|
|
68
|
+
return
|
|
69
|
+
line = progress_line(job_progress(job))
|
|
70
|
+
if line and line != state["line"]:
|
|
71
|
+
print(line, file=sys.stderr, flush=True)
|
|
72
|
+
state["line"] = line
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_logs(train: Any, args: argparse.Namespace):
|
|
76
|
+
color = color_enabled()
|
|
77
|
+
cursor = args.cursor
|
|
78
|
+
tail = args.tail if args.tail is not None else (200 if cursor is None else None)
|
|
79
|
+
retries = 0
|
|
80
|
+
previous_summary = None
|
|
81
|
+
seen = OrderedDict()
|
|
82
|
+
progress = {"next": 0.0, "line": None, "enabled": args.follow and not args.json}
|
|
83
|
+
try:
|
|
84
|
+
while True:
|
|
85
|
+
try:
|
|
86
|
+
page = train.logs(args.job_id, source=args.source, cursor=cursor, tail=tail, limit=args.limit,
|
|
87
|
+
**({"process": args.process} if getattr(args, "process", None) is not None else {}))
|
|
88
|
+
except RemoteError as error:
|
|
89
|
+
transient = error.code in TRANSIENT_CODES or error.status_code in {429, 500, 502, 503, 504}
|
|
90
|
+
if not args.follow or not transient or retries >= 3:
|
|
91
|
+
raise
|
|
92
|
+
retries += 1
|
|
93
|
+
print("Log read temporarily unavailable; retrying.", file=sys.stderr)
|
|
94
|
+
delay = getattr(error, "retry_after", None)
|
|
95
|
+
time.sleep(delay if delay is not None else 2)
|
|
96
|
+
continue
|
|
97
|
+
retries = 0
|
|
98
|
+
if args.follow:
|
|
99
|
+
items = []
|
|
100
|
+
for item in page["items"]:
|
|
101
|
+
identity = item["id"]
|
|
102
|
+
if identity not in seen:
|
|
103
|
+
items.append(item)
|
|
104
|
+
seen[identity] = None
|
|
105
|
+
# Retain a bounded overlap window, independent of log text.
|
|
106
|
+
if len(seen) > 10000:
|
|
107
|
+
seen.popitem(last=False)
|
|
108
|
+
page = {**page, "items": items}
|
|
109
|
+
if args.json:
|
|
110
|
+
# JSON escaping also prevents control sequences from executing in terminals.
|
|
111
|
+
print(json.dumps(page, ensure_ascii=True), flush=True)
|
|
112
|
+
else:
|
|
113
|
+
for item in page["items"]:
|
|
114
|
+
print(log_line(item, color=color), flush=True)
|
|
115
|
+
if progress["enabled"] and time.monotonic() >= progress["next"]:
|
|
116
|
+
show_progress(train, args.job_id, progress)
|
|
117
|
+
summary = capture_summary(page)
|
|
118
|
+
if summary and summary != previous_summary:
|
|
119
|
+
print(summary, file=sys.stderr)
|
|
120
|
+
previous_summary = summary
|
|
121
|
+
if not args.follow or (page.get("eof") and not page.get("has_more")):
|
|
122
|
+
return 0
|
|
123
|
+
next_cursor = page.get("next_cursor")
|
|
124
|
+
if not isinstance(next_cursor, str) or not next_cursor or (page.get("has_more") and next_cursor == cursor):
|
|
125
|
+
raise RemoteError("INVALID_LOG_PAGE")
|
|
126
|
+
cursor, tail = next_cursor, None
|
|
127
|
+
if not page.get("has_more"):
|
|
128
|
+
time.sleep(2)
|
|
129
|
+
except KeyboardInterrupt:
|
|
130
|
+
return 130
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""One-line view of a Train Job's live ``training.progress``; reading never changes the Job."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from envloop.terminal import compact_time, safe_text
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _number(value: Any) -> float | None:
|
|
10
|
+
return value if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) else None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _duration(seconds: float) -> str:
|
|
14
|
+
seconds = round(seconds)
|
|
15
|
+
hours, rest = divmod(seconds, 3600)
|
|
16
|
+
minutes, seconds = divmod(rest, 60)
|
|
17
|
+
return f"{hours}h{minutes:02d}m" if hours else f"{minutes}m{seconds:02d}s" if minutes else f"{seconds}s"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def job_progress(job: Any) -> dict[str, Any] | None:
|
|
21
|
+
"""The ``training.progress`` mapping of a Job response, or None."""
|
|
22
|
+
training = job.get("training") if isinstance(job, dict) else None
|
|
23
|
+
progress = training.get("progress") if isinstance(training, dict) else None
|
|
24
|
+
return progress if isinstance(progress, dict) else None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def progress_line(progress: dict[str, Any] | None) -> str | None:
|
|
28
|
+
"""``Training progress: step 32/66 (48.5%), loss 0.2118, lr 0.0001315, ETA 22m41s, updated ...``.
|
|
29
|
+
|
|
30
|
+
None until the platform has observed a step; a value never observed is omitted, not shown as zero.
|
|
31
|
+
"""
|
|
32
|
+
if not isinstance(progress, dict) or _number(progress.get("step")) is None:
|
|
33
|
+
return None
|
|
34
|
+
step, total = _number(progress.get("step")), _number(progress.get("total_steps"))
|
|
35
|
+
part = f"step {step:g}/{total:g}" if total else f"step {step:g}"
|
|
36
|
+
percent = _number(progress.get("percent"))
|
|
37
|
+
parts = [part + (f" ({percent:g}%)" if percent is not None else "")]
|
|
38
|
+
loss = _number(progress.get("loss"))
|
|
39
|
+
if loss is not None:
|
|
40
|
+
logged = _number(progress.get("metrics_step"))
|
|
41
|
+
# The latest logged loss can belong to an earlier step than the step bar.
|
|
42
|
+
parts.append(f"loss {loss:.4g}" + (f" (step {logged:g})" if logged is not None and logged != step else ""))
|
|
43
|
+
for key, label in (("learning_rate", "lr"), ("grad_norm", "grad_norm")):
|
|
44
|
+
if _number(progress.get(key)) is not None:
|
|
45
|
+
parts.append(f"{label} {progress[key]:.4g}")
|
|
46
|
+
if _number(progress.get("tokens_seen")) is not None:
|
|
47
|
+
parts.append(f"tokens {int(progress['tokens_seen']):,}")
|
|
48
|
+
if _number(progress.get("eta_seconds")) is not None:
|
|
49
|
+
parts.append(f"ETA {_duration(progress['eta_seconds'])}")
|
|
50
|
+
if isinstance(progress.get("updated_at"), str):
|
|
51
|
+
parts.append(f"updated {safe_text(compact_time(progress['updated_at']))}")
|
|
52
|
+
return "Training progress: " + ", ".join(parts)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Translate Runner Train CLI shortcuts into the public Job request schema."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
SHORTCUT_FIELDS = ('docker', 'trails', 'gput_type', 'gpu', 'cpu', 'memory', 'disk', 'shm_size')
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def positive_integer(value: str):
|
|
12
|
+
count = int(value)
|
|
13
|
+
if count <= 0:
|
|
14
|
+
raise ValueError('expected a positive integer')
|
|
15
|
+
return count
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def memory_mebibytes(value: str):
|
|
19
|
+
"""CLI binary units map exactly to the platform's historical *_mb fields."""
|
|
20
|
+
match = re.fullmatch(r'([1-9][0-9]*)(Mi|Gi|Ti)(?:B)?', value)
|
|
21
|
+
if not match:
|
|
22
|
+
raise ValueError('expected a positive integer with Mi, Gi or Ti units (for example 64Gi)')
|
|
23
|
+
return int(match[1]) * {'Mi': 1, 'Gi': 1024, 'Ti': 1024 * 1024}[match[2]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def runner_request(args: argparse.Namespace):
|
|
27
|
+
from envloop_experiment.client import runner_selector
|
|
28
|
+
from envloop_experiment.sandbox import validate_image
|
|
29
|
+
|
|
30
|
+
if not args.runner or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*@[^\s@]+', args.runner):
|
|
31
|
+
raise ValueError('Train Runner requires --runner namespace/name@REVISION')
|
|
32
|
+
runner = runner_selector(args.runner)
|
|
33
|
+
if args.docker is not None and args.image is not None:
|
|
34
|
+
raise ValueError('--docker and --image cannot be combined')
|
|
35
|
+
count = args.trails if args.trails is not None else 1
|
|
36
|
+
if not 1 <= count <= 16:
|
|
37
|
+
raise ValueError('--trails must be from 1 to 16')
|
|
38
|
+
spec: dict[str, Any] = {'runner': runner}
|
|
39
|
+
image = args.docker if args.docker is not None else args.image
|
|
40
|
+
if image is not None:
|
|
41
|
+
try:
|
|
42
|
+
spec['image'] = validate_image(image)
|
|
43
|
+
except ValueError:
|
|
44
|
+
raise ValueError('Train image must be a Docker image name, optionally with a tag or sha256 digest') from None
|
|
45
|
+
if args.command is not None:
|
|
46
|
+
try:
|
|
47
|
+
valid_command = bool(args.command.strip()) and '\0' not in args.command and len(args.command.encode('utf-8')) <= 65536
|
|
48
|
+
except UnicodeEncodeError:
|
|
49
|
+
valid_command = False
|
|
50
|
+
if not valid_command:
|
|
51
|
+
raise ValueError('--command must be non-empty UTF-8, without NUL, at most 65536 bytes')
|
|
52
|
+
spec['command'] = args.command
|
|
53
|
+
resources = {field: getattr(args, option) for option, field in (
|
|
54
|
+
('gput_type', 'gpu_type'), ('gpu', 'gpus'), ('cpu', 'cpus'),
|
|
55
|
+
('memory', 'memory_mb'), ('disk', 'storage_mb'), ('shm_size', 'shm_mb'))
|
|
56
|
+
if getattr(args, option) is not None}
|
|
57
|
+
if args.gput_type is not None and not args.gput_type.strip():
|
|
58
|
+
raise ValueError('--gput-type must be non-empty')
|
|
59
|
+
if resources:
|
|
60
|
+
spec['resources'] = resources
|
|
61
|
+
if args.timeout_sec is not None:
|
|
62
|
+
if args.timeout_sec <= 0 or not args.timeout_sec.is_integer():
|
|
63
|
+
raise ValueError('Train Runner --timeout-sec must be positive whole seconds')
|
|
64
|
+
spec['timeout_sec'] = int(args.timeout_sec)
|
|
65
|
+
return {'type': 'train', 'request_id': args.request_id if args.request_id is not None else str(uuid4()),
|
|
66
|
+
'trials': [{'name': f'trial-{index + 1}', 'spec': dict(spec)} for index in range(count)]}
|