envloop-cli 1.10.0__tar.gz → 1.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/PKG-INFO +2 -1
  2. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/cli.py +9 -0
  3. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/client.py +7 -3
  4. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/job_logs.py +6 -2
  5. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/train.py +7 -3
  6. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/train_log_cli.py +28 -1
  7. envloop_cli-1.13.0/envloop/train_progress.py +51 -0
  8. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/pyproject.toml +2 -2
  9. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/.gitignore +0 -0
  10. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/README.pypi.md +0 -0
  11. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/__init__.py +0 -0
  12. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/agent_runs.py +0 -0
  13. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/auth_cli.py +0 -0
  14. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/auth_login.py +0 -0
  15. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/auth_workspace.py +0 -0
  16. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/cli_errors.py +0 -0
  17. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/cli_output.py +0 -0
  18. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/config.py +0 -0
  19. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/http.py +0 -0
  20. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/inference.py +0 -0
  21. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/job_spec_cli.py +0 -0
  22. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/lifecycle_cli.py +0 -0
  23. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/navigation_resources.py +0 -0
  24. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/profile_secrets.py +0 -0
  25. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/profile_updates.py +0 -0
  26. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/resource_cli.py +0 -0
  27. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/resources.py +0 -0
  28. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/rsi_cli.py +0 -0
  29. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/sandbox.py +0 -0
  30. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/terminal.py +0 -0
  31. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/train_runner_cli.py +0 -0
  32. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/training/__init__.py +0 -0
  33. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/utils/__init__.py +0 -0
  34. {envloop_cli-1.10.0 → envloop_cli-1.13.0}/envloop/utils/logging.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: envloop-cli
3
- Version: 1.10.0
3
+ Version: 1.13.0
4
4
  Summary: EnvLoop CLI and Python SDK for jobs, training, checkpoints and inference
5
5
  Project-URL: Homepage, https://envloop.ai
6
6
  Project-URL: Documentation, https://docs.envloop.ai
@@ -8,6 +8,7 @@ Project-URL: Repository, https://github.com/EnvLoop/envloop-cli
8
8
  Project-URL: Issues, https://github.com/EnvLoop/envloop-cli/issues
9
9
  Project-URL: Agent instructions, https://docs.envloop.ai/skill.md
10
10
  Requires-Python: >=3.11
11
+ Requires-Dist: certifi>=2026.10.19
11
12
  Requires-Dist: tinker==0.34.0
12
13
  Description-Content-Type: text/markdown
13
14
 
@@ -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)
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "envloop-cli"
3
- version = "1.10.0"
3
+ version = "1.13.0"
4
4
  description = "EnvLoop CLI and Python SDK for jobs, training, checkpoints and inference"
5
5
  requires-python = ">=3.11"
6
6
  readme = "README.pypi.md"
7
7
  # Kept deliberately (owner decision) although the public interactive SDK is gone; dropping it is a separate release.
8
- dependencies = ["tinker==0.34.0"]
8
+ dependencies = ["tinker==0.34.0", "certifi>=2026.10.19"]
9
9
 
10
10
  [project.scripts]
11
11
  envloop = "envloop.cli:main"
File without changes