cortex-training 0.0.3__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.
- cortex_training/__init__.py +49 -0
- cortex_training/__main__.py +20 -0
- cortex_training/_cli.py +1050 -0
- cortex_training/cli.py +103 -0
- cortex_training/client.py +2866 -0
- cortex_training/peft.py +112 -0
- cortex_training/telemetry.py +526 -0
- cortex_training/tui/__init__.py +23 -0
- cortex_training/tui/__main__.py +109 -0
- cortex_training/tui/app.py +646 -0
- cortex_training/tui/format.py +389 -0
- cortex_training/tui/log_cache.py +415 -0
- cortex_training/wire.py +414 -0
- cortex_training-0.0.3.dist-info/METADATA +143 -0
- cortex_training-0.0.3.dist-info/RECORD +19 -0
- cortex_training-0.0.3.dist-info/WHEEL +5 -0
- cortex_training-0.0.3.dist-info/entry_points.txt +3 -0
- cortex_training-0.0.3.dist-info/licenses/LICENSE +177 -0
- cortex_training-0.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# Copyright 2025 Snowflake Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Pure formatting helpers for the Cortex Training log/event TUI.
|
|
17
|
+
|
|
18
|
+
Kept free of any ``textual`` import so they can be unit-tested without the
|
|
19
|
+
optional TUI dependency installed.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import textwrap
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from datetime import timezone
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
EVENTS_SOURCE_ID = "__events__"
|
|
31
|
+
EVENTS_LABEL = "▸ scheduling events"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def format_log_entry(entry: Any) -> str:
|
|
35
|
+
"""Render one log entry (a parsed JSONL object or ``{"_raw": ...}``)."""
|
|
36
|
+
if not isinstance(entry, dict):
|
|
37
|
+
return str(entry)
|
|
38
|
+
if "_raw" in entry:
|
|
39
|
+
text = str(entry["_raw"])
|
|
40
|
+
return text + " …[truncated]" if entry.get("_truncated") else text
|
|
41
|
+
ts = str(entry.get("ts", "")).strip()
|
|
42
|
+
level = str(entry.get("level", "")).strip()
|
|
43
|
+
logger = str(entry.get("logger", "")).strip()
|
|
44
|
+
msg = entry.get("msg")
|
|
45
|
+
if msg is None:
|
|
46
|
+
msg = entry.get("message")
|
|
47
|
+
if msg is None:
|
|
48
|
+
# Unknown shape: show the raw JSON so nothing is silently dropped.
|
|
49
|
+
msg = json.dumps(entry, sort_keys=True)
|
|
50
|
+
parts = []
|
|
51
|
+
if ts:
|
|
52
|
+
parts.append(ts)
|
|
53
|
+
if level:
|
|
54
|
+
parts.append(f"{level:<5}")
|
|
55
|
+
if logger:
|
|
56
|
+
parts.append(f"{logger}:")
|
|
57
|
+
parts.append(str(msg))
|
|
58
|
+
line = " ".join(parts)
|
|
59
|
+
if entry.get("_truncated"):
|
|
60
|
+
line += " …[truncated]"
|
|
61
|
+
return line
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def wrap_log_line(text: Any, width: int) -> list[str]:
|
|
65
|
+
"""Soft-wrap one log line to ``width`` columns for display.
|
|
66
|
+
|
|
67
|
+
The ``Log`` widget (unlike ``RichLog``) does not wrap, so we pre-wrap. Word
|
|
68
|
+
boundaries are preferred but long tokens (URLs, JSON blobs) are hard-broken
|
|
69
|
+
so nothing overflows. Embedded newlines split into separate lines first, and
|
|
70
|
+
leading whitespace (indentation) is preserved. ``width <= 0`` (not laid out
|
|
71
|
+
yet) returns the line unwrapped.
|
|
72
|
+
"""
|
|
73
|
+
out: list[str] = []
|
|
74
|
+
for logical in str(text).split("\n"):
|
|
75
|
+
if width <= 0 or len(logical) <= width:
|
|
76
|
+
out.append(logical)
|
|
77
|
+
continue
|
|
78
|
+
pieces = textwrap.wrap(
|
|
79
|
+
logical,
|
|
80
|
+
width=width,
|
|
81
|
+
break_long_words=True,
|
|
82
|
+
break_on_hyphens=False,
|
|
83
|
+
replace_whitespace=False,
|
|
84
|
+
drop_whitespace=False,
|
|
85
|
+
)
|
|
86
|
+
out.extend(pieces or [""])
|
|
87
|
+
return out or [""]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def format_event(ev: Any) -> str:
|
|
91
|
+
"""Render one zone scheduling event."""
|
|
92
|
+
if not isinstance(ev, dict):
|
|
93
|
+
return str(ev)
|
|
94
|
+
parts = []
|
|
95
|
+
ts = str(ev.get("ts", "")).strip()
|
|
96
|
+
if ts:
|
|
97
|
+
parts.append(ts)
|
|
98
|
+
parts.append(str(ev.get("kind", "event")))
|
|
99
|
+
if zone := ev.get("zone"):
|
|
100
|
+
parts.append(f"zone={zone}")
|
|
101
|
+
frm, to = ev.get("from"), ev.get("to")
|
|
102
|
+
if frm or to:
|
|
103
|
+
parts.append(f"{frm or '?'}→{to or '?'}")
|
|
104
|
+
if sub := ev.get("sub_job_id"):
|
|
105
|
+
parts.append(f"sub_job={sub}")
|
|
106
|
+
if detail := ev.get("detail"):
|
|
107
|
+
parts.append(str(detail))
|
|
108
|
+
return " ".join(parts)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def source_label(source: Any) -> str:
|
|
112
|
+
"""Side-panel label for a sub-job source.
|
|
113
|
+
|
|
114
|
+
A source is a sub-job: ``{"sub_job_id", "job_type"}`` (or just a sub_job_id
|
|
115
|
+
string). Rendered as ``"<type> #<index>"`` — the type comes from job_type
|
|
116
|
+
when present, else the middle field of the ``<uuid>:<type>:<index>`` id.
|
|
117
|
+
"""
|
|
118
|
+
sjid = ""
|
|
119
|
+
job_type = None
|
|
120
|
+
if isinstance(source, dict):
|
|
121
|
+
sjid = str(source.get("sub_job_id") or source.get("id") or source.get("source_id") or "")
|
|
122
|
+
job_type = source.get("job_type") or source.get("type")
|
|
123
|
+
else:
|
|
124
|
+
sjid = str(source)
|
|
125
|
+
parts = sjid.split(":")
|
|
126
|
+
if not job_type and len(parts) >= 2:
|
|
127
|
+
job_type = parts[1] # <uuid>:<type>:<index>
|
|
128
|
+
idx = parts[2] if len(parts) >= 3 else ""
|
|
129
|
+
if job_type:
|
|
130
|
+
return f"{job_type} #{idx}" if idx != "" else str(job_type)
|
|
131
|
+
if isinstance(source, dict) and source.get("label"):
|
|
132
|
+
return str(source["label"])
|
|
133
|
+
return sjid or str(source)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
_ACTIVE_STATUSES = {
|
|
137
|
+
"running",
|
|
138
|
+
"initializing",
|
|
139
|
+
"pending",
|
|
140
|
+
"placing",
|
|
141
|
+
"queued",
|
|
142
|
+
"creating",
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_TERMINAL_STATUSES = {
|
|
146
|
+
"failed",
|
|
147
|
+
"cancelled",
|
|
148
|
+
"canceled",
|
|
149
|
+
"terminated",
|
|
150
|
+
"completed",
|
|
151
|
+
"succeeded",
|
|
152
|
+
"done",
|
|
153
|
+
"error",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def is_active_status(status: Any) -> bool:
|
|
158
|
+
"""Whether a job is still live, i.e. its log tail should keep following.
|
|
159
|
+
|
|
160
|
+
Missing/unknown statuses default to active so a running-but-unclassified job
|
|
161
|
+
never stops tailing; only a recognized terminal status stops the follow.
|
|
162
|
+
"""
|
|
163
|
+
if not status:
|
|
164
|
+
return True
|
|
165
|
+
s = str(status).lower().removeprefix("job_state_")
|
|
166
|
+
if s in _ACTIVE_STATUSES:
|
|
167
|
+
return True
|
|
168
|
+
if s in _TERMINAL_STATUSES:
|
|
169
|
+
return False
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def format_job_row(job: Any) -> str:
|
|
174
|
+
"""One job-picker row with │-separated columns: status │ created │ id │ types."""
|
|
175
|
+
if not isinstance(job, dict):
|
|
176
|
+
return str(job)
|
|
177
|
+
jid = job.get("job_id") or job.get("id") or "?"
|
|
178
|
+
status = str(job.get("status", "")).strip() or "?"
|
|
179
|
+
status = status.removeprefix("JOB_STATE_")
|
|
180
|
+
created = format_created_at(job.get("created_at")) or "—"
|
|
181
|
+
# Fixed-width columns + separators so fields stay visually distinct.
|
|
182
|
+
row = f"{status:<10} │ {created:<16} │ {jid}"
|
|
183
|
+
subs = job.get("sub_jobs") or []
|
|
184
|
+
types = [s.get("job_type") or s.get("type") for s in subs if isinstance(s, dict)]
|
|
185
|
+
types = [t for t in types if t]
|
|
186
|
+
if not types and job.get("job_type"):
|
|
187
|
+
types = [job["job_type"]]
|
|
188
|
+
if types:
|
|
189
|
+
row += f" │ {', '.join(map(str, types))}"
|
|
190
|
+
return row
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def job_status_color(status: Any) -> str:
|
|
194
|
+
"""Subtle accent for a job's *status word only* (empty = default color).
|
|
195
|
+
|
|
196
|
+
Deliberately minimal: a gentle green marks live jobs so you can spot the one
|
|
197
|
+
you're running; every other status (failed/cancelled/terminated/unknown)
|
|
198
|
+
keeps the default foreground — no alarm colors.
|
|
199
|
+
"""
|
|
200
|
+
s = str(status or "").strip().lower().removeprefix("job_state_")
|
|
201
|
+
if s in _ACTIVE_STATUSES:
|
|
202
|
+
return "green"
|
|
203
|
+
return ""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def format_created_at(raw: Any) -> str:
|
|
207
|
+
"""Render an ISO-8601 created_at (e.g. ``2026-06-13T18:49:54Z``) as local
|
|
208
|
+
``YYYY-MM-DD HH:MM``. Returns ``""`` for missing/unparseable values."""
|
|
209
|
+
if not raw:
|
|
210
|
+
return ""
|
|
211
|
+
s = str(raw).strip()
|
|
212
|
+
if s.endswith("Z"):
|
|
213
|
+
s = s[:-1] + "+00:00"
|
|
214
|
+
try:
|
|
215
|
+
dt = datetime.fromisoformat(s)
|
|
216
|
+
except ValueError:
|
|
217
|
+
return ""
|
|
218
|
+
if dt.tzinfo is None:
|
|
219
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
220
|
+
return dt.astimezone().strftime("%Y-%m-%d %H:%M")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _created_epoch(raw: Any) -> float:
|
|
224
|
+
"""Parse a created_at into epoch seconds for sorting. Missing/unparseable
|
|
225
|
+
values sort oldest (``-inf``)."""
|
|
226
|
+
if not raw:
|
|
227
|
+
return float("-inf")
|
|
228
|
+
s = str(raw).strip()
|
|
229
|
+
if s.endswith("Z"):
|
|
230
|
+
s = s[:-1] + "+00:00"
|
|
231
|
+
try:
|
|
232
|
+
dt = datetime.fromisoformat(s)
|
|
233
|
+
except ValueError:
|
|
234
|
+
return float("-inf")
|
|
235
|
+
if dt.tzinfo is None:
|
|
236
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
237
|
+
return dt.timestamp()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def sort_jobs(jobs: Any) -> list:
|
|
241
|
+
"""Order jobs for the picker newest-first by created_at. Ties keep their
|
|
242
|
+
original order (stable); jobs without a parseable created_at sort last."""
|
|
243
|
+
if not isinstance(jobs, list):
|
|
244
|
+
return []
|
|
245
|
+
return sorted(
|
|
246
|
+
[j for j in jobs if isinstance(j, dict)],
|
|
247
|
+
key=lambda job: _created_epoch(job.get("created_at")),
|
|
248
|
+
reverse=True,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ─── Job summary (whitelisted create-job params) ─────────────────────────────
|
|
253
|
+
|
|
254
|
+
# Only the parameters a customer passes to SubJobConfig.training_job /
|
|
255
|
+
# sampling_job are surfaced — short label + the config key it reads.
|
|
256
|
+
_SUMMARY_CONFIG_KEYS = [
|
|
257
|
+
("n_gpus", "gpus"),
|
|
258
|
+
("max_seq_len", "seq"),
|
|
259
|
+
("train_batch_size", "bs"),
|
|
260
|
+
("gradient_clipping", "clip"),
|
|
261
|
+
]
|
|
262
|
+
_SUMMARY_SUBJOB_KEYS = [
|
|
263
|
+
("global_batch_size", "gbs"),
|
|
264
|
+
("dtype", "dtype"),
|
|
265
|
+
("seed", "seed"),
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _num(v: Any) -> str:
|
|
270
|
+
"""Render a value compactly: integral floats lose the .0 (n_gpus 1.0 -> 1)."""
|
|
271
|
+
if isinstance(v, bool):
|
|
272
|
+
return str(v)
|
|
273
|
+
if isinstance(v, float) and v.is_integer():
|
|
274
|
+
return str(int(v))
|
|
275
|
+
return str(v)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _subjob_config(sj: dict) -> dict:
|
|
279
|
+
for key in ("training_config", "inference_config", "sampling_config"):
|
|
280
|
+
cfg = sj.get(key)
|
|
281
|
+
if isinstance(cfg, dict):
|
|
282
|
+
return cfg
|
|
283
|
+
return {}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _job_config_segments(job: dict) -> list:
|
|
287
|
+
"""Per-sub-job static config segments (type / model / whitelisted params)."""
|
|
288
|
+
segs = []
|
|
289
|
+
for sj in job.get("sub_jobs") or []:
|
|
290
|
+
if not isinstance(sj, dict):
|
|
291
|
+
continue
|
|
292
|
+
seg = []
|
|
293
|
+
jt = str(sj.get("job_type") or sj.get("type") or "").lower()
|
|
294
|
+
if jt:
|
|
295
|
+
seg.append(jt)
|
|
296
|
+
if sj.get("model_name"):
|
|
297
|
+
seg.append(str(sj["model_name"]))
|
|
298
|
+
cfg = _subjob_config(sj)
|
|
299
|
+
for key, label in _SUMMARY_CONFIG_KEYS:
|
|
300
|
+
if cfg.get(key) is not None:
|
|
301
|
+
seg.append(f"{label}={_num(cfg[key])}")
|
|
302
|
+
opt = cfg.get("optimizer")
|
|
303
|
+
if isinstance(opt, dict):
|
|
304
|
+
o = [str(opt["name"])] if opt.get("name") else []
|
|
305
|
+
if opt.get("lr") is not None:
|
|
306
|
+
o.append(f"lr={_num(opt['lr'])}")
|
|
307
|
+
if o:
|
|
308
|
+
seg.append("opt=" + "/".join(o))
|
|
309
|
+
for key, label in _SUMMARY_SUBJOB_KEYS:
|
|
310
|
+
if sj.get(key) is not None:
|
|
311
|
+
seg.append(f"{label}={_num(sj[key])}")
|
|
312
|
+
if seg:
|
|
313
|
+
segs.append(" ".join(seg))
|
|
314
|
+
return segs
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def format_job_config(job: Any) -> str:
|
|
318
|
+
"""Static create-time config only (no status / reason) — for the line we
|
|
319
|
+
reserve to describe the job. Shows only whitelisted params that are present,
|
|
320
|
+
so it stays compact."""
|
|
321
|
+
if not isinstance(job, dict):
|
|
322
|
+
return ""
|
|
323
|
+
return " · ".join(_job_config_segments(job))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def format_job_summary(job: Any) -> str:
|
|
327
|
+
"""One-line summary of a job's create-time config (whitelisted) + status.
|
|
328
|
+
|
|
329
|
+
Shows only the parameters the customer passed to create_job that are
|
|
330
|
+
present, so it stays compact. Failure/cancel ``reason`` is appended.
|
|
331
|
+
"""
|
|
332
|
+
if not isinstance(job, dict):
|
|
333
|
+
return ""
|
|
334
|
+
status = (str(job.get("status", "")).strip() or "?").removeprefix("JOB_STATE_")
|
|
335
|
+
parts = [status, *_job_config_segments(job)]
|
|
336
|
+
if job.get("reason"):
|
|
337
|
+
parts.append(f"reason: {job['reason']}")
|
|
338
|
+
return " · ".join(p for p in parts if p)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ─── Search / filter helpers ─────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def job_matches(job: Any, query: str) -> bool:
|
|
345
|
+
"""Case-insensitive match of query against a job's id, status, sub-job
|
|
346
|
+
types and model names. Empty query matches everything."""
|
|
347
|
+
if not query:
|
|
348
|
+
return True
|
|
349
|
+
if not isinstance(job, dict):
|
|
350
|
+
return False
|
|
351
|
+
hay = [str(job.get("job_id") or job.get("id") or ""), str(job.get("status") or "")]
|
|
352
|
+
for sj in job.get("sub_jobs") or []:
|
|
353
|
+
if isinstance(sj, dict):
|
|
354
|
+
hay.append(str(sj.get("job_type") or sj.get("type") or ""))
|
|
355
|
+
hay.append(str(sj.get("model_name") or ""))
|
|
356
|
+
return query.lower() in " ".join(hay).lower()
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def entry_matches(entry: Any, query: str) -> bool:
|
|
360
|
+
"""Whether a log entry's rendered text contains query (case-insensitive)."""
|
|
361
|
+
if not query:
|
|
362
|
+
return True
|
|
363
|
+
return query.lower() in format_log_entry(entry).lower()
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
_LEVELS = {
|
|
367
|
+
"DEBUG": 10,
|
|
368
|
+
"INFO": 20,
|
|
369
|
+
"WARNING": 30,
|
|
370
|
+
"WARN": 30,
|
|
371
|
+
"ERROR": 40,
|
|
372
|
+
"CRITICAL": 50,
|
|
373
|
+
"FATAL": 50,
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def entry_at_level(entry: Any, min_level: Any) -> bool:
|
|
378
|
+
"""Whether a structured entry is at or above min_level. Entries without a
|
|
379
|
+
parseable level (raw stdout lines) always pass, so the filter only trims
|
|
380
|
+
lower-severity *structured* logs and never hides raw output."""
|
|
381
|
+
if not min_level:
|
|
382
|
+
return True
|
|
383
|
+
floor = _LEVELS.get(str(min_level).upper())
|
|
384
|
+
if floor is None:
|
|
385
|
+
return True
|
|
386
|
+
lvl = entry.get("level") if isinstance(entry, dict) else None
|
|
387
|
+
if not lvl:
|
|
388
|
+
return True
|
|
389
|
+
return _LEVELS.get(str(lvl).upper(), 0) >= floor
|