envloop-cli 1.10.0__tar.gz → 1.12.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.10.0 → envloop_cli-1.12.0}/PKG-INFO +1 -1
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/cli.py +9 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/client.py +7 -3
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/job_logs.py +6 -2
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/train.py +7 -3
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/train_log_cli.py +28 -1
- envloop_cli-1.12.0/envloop/train_progress.py +51 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/pyproject.toml +1 -1
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/.gitignore +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/README.pypi.md +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/__init__.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/agent_runs.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/auth_cli.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/auth_login.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/auth_workspace.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/cli_errors.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/cli_output.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/config.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/http.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/inference.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/job_spec_cli.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/lifecycle_cli.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/navigation_resources.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/profile_secrets.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/profile_updates.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/resource_cli.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/resources.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/rsi_cli.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/sandbox.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/terminal.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/train_runner_cli.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/training/__init__.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/utils/__init__.py +0 -0
- {envloop_cli-1.10.0 → envloop_cli-1.12.0}/envloop/utils/logging.py +0 -0
|
@@ -221,6 +221,8 @@ def parser():
|
|
|
221
221
|
train_log = jobs.add_parser("log", help="Read or follow Agent, Sandbox or Train Job logs")
|
|
222
222
|
train_log.add_argument("job_id")
|
|
223
223
|
train_log.add_argument("--source", choices=("all", "system", "runtime"), default="all")
|
|
224
|
+
train_log.add_argument("--process", metavar="NAME",
|
|
225
|
+
help="Only one recorded process, e.g. trainer (a Train Job's step metrics)")
|
|
224
226
|
position = train_log.add_mutually_exclusive_group()
|
|
225
227
|
position.add_argument("--tail", type=log_page_size, help="Start with the last N records (default: 200)")
|
|
226
228
|
position.add_argument("--cursor", help="Resume from a previously returned log cursor")
|
|
@@ -609,6 +611,13 @@ def _main(argv=None):
|
|
|
609
611
|
result = client.jobs.list(**options)
|
|
610
612
|
else:
|
|
611
613
|
result = getattr(client.jobs, 'status' if args.action == 'get' else args.action)(args.job_id)
|
|
614
|
+
if args.action == 'get':
|
|
615
|
+
from .train_progress import job_progress, progress_line
|
|
616
|
+
|
|
617
|
+
# The JSON on stdout is unchanged; a Train Job's live progress is a readable stderr line.
|
|
618
|
+
line = progress_line(job_progress(result))
|
|
619
|
+
if line:
|
|
620
|
+
print(line, file=sys.stderr)
|
|
612
621
|
elif args.group == "trail":
|
|
613
622
|
if args.action == "artifact":
|
|
614
623
|
if args.artifact_action == "download":
|
|
@@ -582,11 +582,15 @@ class Jobs:
|
|
|
582
582
|
def list(self, **kwargs):
|
|
583
583
|
return self._server.list_jobs(**kwargs)
|
|
584
584
|
|
|
585
|
-
def logs(self, job_id, *, source='all', cursor=None, tail=None, limit=200):
|
|
586
|
-
"""Read captured Job output, retaining pagination and collection metadata.
|
|
585
|
+
def logs(self, job_id, *, source='all', cursor=None, tail=None, limit=200, process=None):
|
|
586
|
+
"""Read captured Job output, retaining pagination and collection metadata.
|
|
587
|
+
|
|
588
|
+
``process`` selects one recorded process, e.g. ``"trainer"`` for a Train Job's step metrics.
|
|
589
|
+
"""
|
|
587
590
|
from .job_logs import log_query
|
|
588
591
|
|
|
589
|
-
return self._server.get_job_logs(job_id, **log_query(source=source, cursor=cursor, tail=tail, limit=limit
|
|
592
|
+
return self._server.get_job_logs(job_id, **log_query(source=source, cursor=cursor, tail=tail, limit=limit,
|
|
593
|
+
process=process))
|
|
590
594
|
|
|
591
595
|
def stop(self, job_id):
|
|
592
596
|
return self._server.stop_job(job_id)
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
"""Selection shared by generic and training Job log views."""
|
|
2
|
+
import re
|
|
2
3
|
|
|
3
4
|
|
|
4
|
-
def log_query(*, source='all', cursor=None, tail=None, limit=200):
|
|
5
|
+
def log_query(*, source='all', cursor=None, tail=None, limit=200, process=None):
|
|
5
6
|
if source not in {'all', 'system', 'runtime'}:
|
|
6
7
|
raise ValueError('source must be all, system or runtime')
|
|
8
|
+
# One recorded process, for example ``trainer`` (the trainer's step metrics) or ``controller``.
|
|
9
|
+
if process is not None and (not isinstance(process, str) or not re.fullmatch(r'[A-Za-z0-9._:-]{1,128}', process)):
|
|
10
|
+
raise ValueError('process must be 1-128 letters, digits, ".", "_", ":" or "-"')
|
|
7
11
|
for name, value in (('limit', limit), ('tail', tail)):
|
|
8
12
|
if (name == 'limit' or value is not None) and (type(value) is not int or not 1 <= value <= 1000):
|
|
9
13
|
raise ValueError(f'{name} must be an integer from 1 to 1000')
|
|
@@ -12,5 +16,5 @@ def log_query(*, source='all', cursor=None, tail=None, limit=200):
|
|
|
12
16
|
if cursor is not None and tail is not None:
|
|
13
17
|
raise ValueError('cursor and tail are mutually exclusive')
|
|
14
18
|
return {key: value for key, value in {
|
|
15
|
-
'source': source, 'cursor': cursor, 'tail': tail, 'limit': limit,
|
|
19
|
+
'source': source, 'process': process, 'cursor': cursor, 'tail': tail, 'limit': limit,
|
|
16
20
|
}.items() if value is not None}
|
|
@@ -52,11 +52,15 @@ class TrainJobs:
|
|
|
52
52
|
def result(self, job_id: str) -> dict:
|
|
53
53
|
return self._view(self.transport.request('GET', f'/v1/job/{quote(job_id, safe="")}/result'))
|
|
54
54
|
|
|
55
|
-
def logs(self, job_id: str, *, source="all", cursor=None, tail=None, limit=200) -> dict:
|
|
56
|
-
"""Read a log page; cursors retain the server's source and capture metadata.
|
|
55
|
+
def logs(self, job_id: str, *, source="all", cursor=None, tail=None, limit=200, process=None) -> dict:
|
|
56
|
+
"""Read a log page; cursors retain the server's source and capture metadata.
|
|
57
|
+
|
|
58
|
+
``process="trainer"`` reads only the trainer's step metric lines. Live step, loss and ETA
|
|
59
|
+
are ``status(job_id)["progress"]``.
|
|
60
|
+
"""
|
|
57
61
|
from .job_logs import log_query
|
|
58
62
|
|
|
59
|
-
query = urlencode(log_query(source=source, cursor=cursor, tail=tail, limit=limit))
|
|
63
|
+
query = urlencode(log_query(source=source, cursor=cursor, tail=tail, limit=limit, process=process))
|
|
60
64
|
return self.transport.request("GET", f"/v1/job/{quote(job_id, safe='')}/logs?{query}")
|
|
61
65
|
|
|
62
66
|
def stop(self, job_id: str) -> dict:
|
|
@@ -7,12 +7,19 @@ from collections import OrderedDict
|
|
|
7
7
|
|
|
8
8
|
from .http import RemoteError
|
|
9
9
|
from .terminal import color_enabled, compact_time, safe_text, status_text, style
|
|
10
|
+
from .train_progress import job_progress, progress_line
|
|
11
|
+
|
|
12
|
+
# While following a Train Job, re-read its progress at most this often (the platform observes it about every 30 s).
|
|
13
|
+
PROGRESS_POLL_SEC = 30
|
|
10
14
|
|
|
11
15
|
|
|
12
16
|
def log_line(item, *, color=False):
|
|
13
17
|
timestamp = compact_time(safe_text(item.get("at") or ""))
|
|
14
18
|
level = safe_text(item.get("level") or "info")
|
|
15
19
|
message = safe_text(item["content"])
|
|
20
|
+
# Captured lines keep their own line break; print adds one, so drop it rather than double-space.
|
|
21
|
+
if message.endswith("\n"):
|
|
22
|
+
message = message[:-1]
|
|
16
23
|
if item.get("code"):
|
|
17
24
|
diagnostic = safe_text(item["code"])
|
|
18
25
|
if item.get("phase"):
|
|
@@ -47,6 +54,22 @@ def capture_summary(page):
|
|
|
47
54
|
return "Log collection: " + "; ".join(details) if details else ""
|
|
48
55
|
|
|
49
56
|
|
|
57
|
+
def show_progress(train, job_id, state):
|
|
58
|
+
"""Print a Train Job's progress line to stderr when it changes; other Job types stop the polling."""
|
|
59
|
+
state["next"] = time.monotonic() + PROGRESS_POLL_SEC
|
|
60
|
+
try:
|
|
61
|
+
job = train.status(job_id)
|
|
62
|
+
except RemoteError:
|
|
63
|
+
return # Progress is an observation; a failed read never interrupts the log stream.
|
|
64
|
+
if not isinstance(job, dict) or job.get("type") not in {None, "train"}:
|
|
65
|
+
state["enabled"] = False
|
|
66
|
+
return
|
|
67
|
+
line = progress_line(job_progress(job))
|
|
68
|
+
if line and line != state["line"]:
|
|
69
|
+
print(line, file=sys.stderr, flush=True)
|
|
70
|
+
state["line"] = line
|
|
71
|
+
|
|
72
|
+
|
|
50
73
|
def read_logs(train, args):
|
|
51
74
|
color = color_enabled()
|
|
52
75
|
cursor = args.cursor
|
|
@@ -54,10 +77,12 @@ def read_logs(train, args):
|
|
|
54
77
|
retries = 0
|
|
55
78
|
previous_summary = None
|
|
56
79
|
seen = OrderedDict()
|
|
80
|
+
progress = {"next": 0.0, "line": None, "enabled": args.follow and not args.json}
|
|
57
81
|
try:
|
|
58
82
|
while True:
|
|
59
83
|
try:
|
|
60
|
-
page = train.logs(args.job_id, source=args.source, cursor=cursor, tail=tail, limit=args.limit
|
|
84
|
+
page = train.logs(args.job_id, source=args.source, cursor=cursor, tail=tail, limit=args.limit,
|
|
85
|
+
**({"process": args.process} if getattr(args, "process", None) is not None else {}))
|
|
61
86
|
except RemoteError as error:
|
|
62
87
|
transient = error.code == "NETWORK_ERROR" or error.status_code in {429, 500, 502, 503, 504}
|
|
63
88
|
if not args.follow or not transient or retries >= 3:
|
|
@@ -85,6 +110,8 @@ def read_logs(train, args):
|
|
|
85
110
|
else:
|
|
86
111
|
for item in page["items"]:
|
|
87
112
|
print(log_line(item, color=color), flush=True)
|
|
113
|
+
if progress["enabled"] and time.monotonic() >= progress["next"]:
|
|
114
|
+
show_progress(train, args.job_id, progress)
|
|
88
115
|
summary = capture_summary(page)
|
|
89
116
|
if summary and summary != previous_summary:
|
|
90
117
|
print(summary, file=sys.stderr)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""One-line view of a Train Job's live ``training.progress``; reading never changes the Job."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
from .terminal import compact_time, safe_text
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _number(value):
|
|
9
|
+
return value if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) else None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _duration(seconds):
|
|
13
|
+
seconds = int(round(seconds))
|
|
14
|
+
hours, rest = divmod(seconds, 3600)
|
|
15
|
+
minutes, seconds = divmod(rest, 60)
|
|
16
|
+
return f"{hours}h{minutes:02d}m" if hours else f"{minutes}m{seconds:02d}s" if minutes else f"{seconds}s"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def job_progress(job):
|
|
20
|
+
"""The ``training.progress`` mapping of a Job response, or None."""
|
|
21
|
+
training = job.get("training") if isinstance(job, dict) else None
|
|
22
|
+
progress = training.get("progress") if isinstance(training, dict) else None
|
|
23
|
+
return progress if isinstance(progress, dict) else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def progress_line(progress):
|
|
27
|
+
"""``Training progress: step 32/66 (48.5%), loss 0.2118, lr 0.0001315, ETA 22m41s, updated ...``.
|
|
28
|
+
|
|
29
|
+
None until the platform has observed a step; a value never observed is omitted, not shown as zero.
|
|
30
|
+
"""
|
|
31
|
+
if not isinstance(progress, dict) or _number(progress.get("step")) is None:
|
|
32
|
+
return None
|
|
33
|
+
step, total = _number(progress.get("step")), _number(progress.get("total_steps"))
|
|
34
|
+
part = f"step {step:g}/{total:g}" if total else f"step {step:g}"
|
|
35
|
+
percent = _number(progress.get("percent"))
|
|
36
|
+
parts = [part + (f" ({percent:g}%)" if percent is not None else "")]
|
|
37
|
+
loss = _number(progress.get("loss"))
|
|
38
|
+
if loss is not None:
|
|
39
|
+
logged = _number(progress.get("metrics_step"))
|
|
40
|
+
# The latest logged loss can belong to an earlier step than the step bar.
|
|
41
|
+
parts.append(f"loss {loss:.4g}" + (f" (step {logged:g})" if logged is not None and logged != step else ""))
|
|
42
|
+
for key, label in (("learning_rate", "lr"), ("grad_norm", "grad_norm")):
|
|
43
|
+
if _number(progress.get(key)) is not None:
|
|
44
|
+
parts.append(f"{label} {progress[key]:.4g}")
|
|
45
|
+
if _number(progress.get("tokens_seen")) is not None:
|
|
46
|
+
parts.append(f"tokens {int(progress['tokens_seen']):,}")
|
|
47
|
+
if _number(progress.get("eta_seconds")) is not None:
|
|
48
|
+
parts.append(f"ETA {_duration(progress['eta_seconds'])}")
|
|
49
|
+
if isinstance(progress.get("updated_at"), str):
|
|
50
|
+
parts.append(f"updated {safe_text(compact_time(progress['updated_at']))}")
|
|
51
|
+
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
|
|
File without changes
|
|
File without changes
|