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.
@@ -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)]}
@@ -0,0 +1,4 @@
1
+ """Removed: the public interactive training SDK. Training is managed Jobs only."""
2
+ from envloop.client import INTERACTIVE_TRAINING_REMOVED
3
+
4
+ raise ModuleNotFoundError(INTERACTIVE_TRAINING_REMOVED, name="envloop.training")