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
cortex_training/_cli.py
ADDED
|
@@ -0,0 +1,1050 @@
|
|
|
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
|
+
"""Command-line implementation for Cortex Training job management."""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from datetime import timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
from typing import Callable
|
|
29
|
+
from typing import TextIO
|
|
30
|
+
|
|
31
|
+
_CONFIG_KEYS = {
|
|
32
|
+
"base_url",
|
|
33
|
+
"host",
|
|
34
|
+
"pat",
|
|
35
|
+
"database",
|
|
36
|
+
"schema",
|
|
37
|
+
"endpoint",
|
|
38
|
+
"poll_interval",
|
|
39
|
+
"poll_timeout",
|
|
40
|
+
"no_verify_ssl",
|
|
41
|
+
"verify_ssl",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_CONFIG_ALIASES = {
|
|
45
|
+
"url": "base_url",
|
|
46
|
+
"db": "database",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_LOGIN_STATE_ENV = "CORTEX_TRAINING_LOGIN_FILE"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _env(*names: str) -> str | None:
|
|
53
|
+
for name in names:
|
|
54
|
+
value = os.environ.get(name)
|
|
55
|
+
if value:
|
|
56
|
+
return value
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _normalize_host(host: str) -> str:
|
|
61
|
+
for prefix in ("https://", "http://"):
|
|
62
|
+
if host.startswith(prefix):
|
|
63
|
+
host = host[len(prefix) :]
|
|
64
|
+
return host.rstrip("/")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _has_url_scheme(url: str) -> bool:
|
|
68
|
+
return url.startswith("http://") or url.startswith("https://")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _load_cortex_training_client_class():
|
|
72
|
+
from .client import CortexTrainingClient
|
|
73
|
+
|
|
74
|
+
return CortexTrainingClient
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _load_forward_backward_payload_builder():
|
|
78
|
+
from .client import build_forward_backward_payload
|
|
79
|
+
|
|
80
|
+
return build_forward_backward_payload
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _hardware_choices() -> list[str]:
|
|
84
|
+
from .client import Hardware
|
|
85
|
+
|
|
86
|
+
return [member.value for member in Hardware]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _created_epoch(raw: Any) -> float | None:
|
|
90
|
+
if raw is None:
|
|
91
|
+
return None
|
|
92
|
+
created_at = str(raw).strip()
|
|
93
|
+
if not created_at:
|
|
94
|
+
return None
|
|
95
|
+
if created_at.endswith("Z"):
|
|
96
|
+
created_at = created_at[:-1] + "+00:00"
|
|
97
|
+
try:
|
|
98
|
+
created = datetime.fromisoformat(created_at)
|
|
99
|
+
except ValueError:
|
|
100
|
+
return None
|
|
101
|
+
if created.tzinfo is None:
|
|
102
|
+
created = created.replace(tzinfo=timezone.utc)
|
|
103
|
+
return created.timestamp()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _jobs_latest_last(jobs: list[Any]) -> list[Any]:
|
|
107
|
+
"""Render jobs oldest-first so the newest entries land at the terminal."""
|
|
108
|
+
dated_jobs = [
|
|
109
|
+
(
|
|
110
|
+
_created_epoch(job.get("created_at")) if isinstance(job, dict) else None,
|
|
111
|
+
index,
|
|
112
|
+
job,
|
|
113
|
+
)
|
|
114
|
+
for index, job in enumerate(jobs)
|
|
115
|
+
]
|
|
116
|
+
if any(created is not None for created, _, _ in dated_jobs):
|
|
117
|
+
return [
|
|
118
|
+
job
|
|
119
|
+
for _, _, job in sorted(
|
|
120
|
+
dated_jobs,
|
|
121
|
+
key=lambda item: (
|
|
122
|
+
item[0] is None,
|
|
123
|
+
item[0] if item[0] is not None else float("inf"),
|
|
124
|
+
item[1],
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
]
|
|
128
|
+
return list(reversed(jobs))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_parser(
|
|
132
|
+
*,
|
|
133
|
+
prog: str = "cortex-training",
|
|
134
|
+
include_tui: bool = False,
|
|
135
|
+
) -> argparse.ArgumentParser:
|
|
136
|
+
parser = argparse.ArgumentParser(
|
|
137
|
+
prog=prog,
|
|
138
|
+
description="Manage Cortex Training jobs through the Cortex Training REST endpoint.",
|
|
139
|
+
)
|
|
140
|
+
parser.add_argument(
|
|
141
|
+
"--config",
|
|
142
|
+
default=_env("CORTEX_TRAINING_CONFIG"),
|
|
143
|
+
help="Path to a reusable Cortex Training CLI config JSON file.",
|
|
144
|
+
)
|
|
145
|
+
parser.add_argument(
|
|
146
|
+
"--base-url",
|
|
147
|
+
help="Base URL for a local or otherwise compatible server. Skips PAT auth.",
|
|
148
|
+
)
|
|
149
|
+
parser.add_argument(
|
|
150
|
+
"--host",
|
|
151
|
+
help="Snowflake account host for PAT auth.",
|
|
152
|
+
)
|
|
153
|
+
parser.add_argument(
|
|
154
|
+
"--pat",
|
|
155
|
+
help="Programmatic access token.",
|
|
156
|
+
)
|
|
157
|
+
parser.add_argument(
|
|
158
|
+
"--database",
|
|
159
|
+
help="Database containing the cortex-training endpoint.",
|
|
160
|
+
)
|
|
161
|
+
parser.add_argument(
|
|
162
|
+
"--schema",
|
|
163
|
+
help="Schema containing the cortex-training endpoint. Defaults to PUBLIC.",
|
|
164
|
+
)
|
|
165
|
+
parser.add_argument(
|
|
166
|
+
"--endpoint",
|
|
167
|
+
help="REST endpoint name. Defaults to cortex-training.",
|
|
168
|
+
)
|
|
169
|
+
parser.add_argument(
|
|
170
|
+
"--no-verify-ssl",
|
|
171
|
+
action="store_true",
|
|
172
|
+
help="Disable SSL certificate verification for PAT-authenticated requests.",
|
|
173
|
+
)
|
|
174
|
+
parser.add_argument("--poll-interval", type=float)
|
|
175
|
+
parser.add_argument("--poll-timeout", type=float)
|
|
176
|
+
parser.add_argument(
|
|
177
|
+
"--compact",
|
|
178
|
+
action="store_true",
|
|
179
|
+
help="Print compact JSON instead of pretty JSON.",
|
|
180
|
+
)
|
|
181
|
+
parser.add_argument(
|
|
182
|
+
"--job",
|
|
183
|
+
"--job-id",
|
|
184
|
+
dest="job",
|
|
185
|
+
help="Job id for data-plane commands such as fwd-bwd.",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
189
|
+
|
|
190
|
+
submit = subparsers.add_parser(
|
|
191
|
+
"submit",
|
|
192
|
+
help="Submit a Cortex Training job from a CreateJob JSON file.",
|
|
193
|
+
)
|
|
194
|
+
submit.add_argument(
|
|
195
|
+
"json_file",
|
|
196
|
+
help="Path to CreateJob JSON, or '-' for stdin.",
|
|
197
|
+
)
|
|
198
|
+
submit.add_argument("--job-id", help="Set or override the top-level job_id.")
|
|
199
|
+
submit.add_argument(
|
|
200
|
+
"--dry-run",
|
|
201
|
+
action="store_true",
|
|
202
|
+
help="Validate and print the CreateJob body without sending it.",
|
|
203
|
+
)
|
|
204
|
+
submit.add_argument(
|
|
205
|
+
"--wait",
|
|
206
|
+
action="store_true",
|
|
207
|
+
help="Wait until the submitted job reaches running.",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
get = subparsers.add_parser("get", help="Fetch one Cortex Training job.")
|
|
211
|
+
get.add_argument("job_id")
|
|
212
|
+
|
|
213
|
+
checkpoints = subparsers.add_parser(
|
|
214
|
+
"checkpoints",
|
|
215
|
+
help="List checkpoints for one Cortex Training job.",
|
|
216
|
+
)
|
|
217
|
+
checkpoints.add_argument("job_id")
|
|
218
|
+
|
|
219
|
+
list_jobs = subparsers.add_parser("list", help="List Cortex Training jobs.")
|
|
220
|
+
list_jobs.add_argument("--status", help="Optional status filter.")
|
|
221
|
+
|
|
222
|
+
capacity = subparsers.add_parser(
|
|
223
|
+
"capacity",
|
|
224
|
+
help="Show reserved GPU capacity and current usage for the caller account.",
|
|
225
|
+
)
|
|
226
|
+
capacity.add_argument(
|
|
227
|
+
"--hardware",
|
|
228
|
+
choices=_hardware_choices(),
|
|
229
|
+
help=(
|
|
230
|
+
"Show only this GPU hardware. "
|
|
231
|
+
"Omit to show capacity for every hardware type."
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
cancel = subparsers.add_parser("cancel", help="Cancel one Cortex Training job.")
|
|
236
|
+
cancel.add_argument("job_id")
|
|
237
|
+
|
|
238
|
+
wait = subparsers.add_parser("wait", help="Wait until one job reaches running.")
|
|
239
|
+
wait.add_argument("job_id")
|
|
240
|
+
|
|
241
|
+
fwd_bwd = subparsers.add_parser(
|
|
242
|
+
"fwd-bwd",
|
|
243
|
+
help="Run one forward-backward request from a readable payload JSON file.",
|
|
244
|
+
)
|
|
245
|
+
fwd_bwd.add_argument(
|
|
246
|
+
"json_file",
|
|
247
|
+
help="Path to fwd-bwd JSON, or '-' for stdin.",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
step = subparsers.add_parser(
|
|
251
|
+
"step",
|
|
252
|
+
help="Run one optimizer step for a training job.",
|
|
253
|
+
)
|
|
254
|
+
step.add_argument(
|
|
255
|
+
"--lr",
|
|
256
|
+
type=float,
|
|
257
|
+
default=1e-4,
|
|
258
|
+
help="Learning rate for the optimizer step. Defaults to 1e-4.",
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
load = subparsers.add_parser(
|
|
262
|
+
"load",
|
|
263
|
+
help="Load a checkpoint into an already-created Cortex Training job.",
|
|
264
|
+
)
|
|
265
|
+
load.add_argument("checkpoint_id", help="Checkpoint id/tag to load.")
|
|
266
|
+
load.add_argument(
|
|
267
|
+
"--source-job-id",
|
|
268
|
+
help="Load the checkpoint from another job's checkpoint store.",
|
|
269
|
+
)
|
|
270
|
+
load.add_argument(
|
|
271
|
+
"--target-sub-job-id",
|
|
272
|
+
dest="target_sub_job_id",
|
|
273
|
+
help=(
|
|
274
|
+
"Training sub-job to load the checkpoint into, e.g. JOB_ID:training:0. "
|
|
275
|
+
"Use this when you need explicit routing control; a job has at most one "
|
|
276
|
+
"training sub-job, so omitting it uses that sub-job. "
|
|
277
|
+
"Use 'cortex-training get JOB_ID' to discover available sub-job IDs."
|
|
278
|
+
),
|
|
279
|
+
)
|
|
280
|
+
load.add_argument(
|
|
281
|
+
"--no-poll",
|
|
282
|
+
action="store_true",
|
|
283
|
+
help="Print the request id without polling for completion.",
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
generate = subparsers.add_parser(
|
|
287
|
+
"generate",
|
|
288
|
+
help="Run one generate request from a readable payload JSON file.",
|
|
289
|
+
)
|
|
290
|
+
generate.add_argument(
|
|
291
|
+
"json_file",
|
|
292
|
+
help="Path to generate JSON, or '-' for stdin.",
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
weight_sync = subparsers.add_parser(
|
|
296
|
+
"weight-sync",
|
|
297
|
+
help="Sync weights from a training sub-job to one or more sampling sub-jobs.",
|
|
298
|
+
)
|
|
299
|
+
weight_sync.add_argument(
|
|
300
|
+
"--source-sub-job-id",
|
|
301
|
+
help="Training sub-job id. Defaults to JOB_ID:training:0.",
|
|
302
|
+
)
|
|
303
|
+
weight_sync.add_argument(
|
|
304
|
+
"--target-sub-job-id",
|
|
305
|
+
action="append",
|
|
306
|
+
dest="target_sub_job_ids",
|
|
307
|
+
help="Sampling sub-job id. Can be repeated. Defaults to JOB_ID:sampling:0.",
|
|
308
|
+
)
|
|
309
|
+
weight_sync.add_argument(
|
|
310
|
+
"--operation-sub-job-id",
|
|
311
|
+
help="Sub-job id used to route the operation envelope. Defaults to the source training sub-job id.",
|
|
312
|
+
)
|
|
313
|
+
weight_sync.add_argument(
|
|
314
|
+
"--operation-sub-job-type",
|
|
315
|
+
help="Sub-job type used to route the operation envelope.",
|
|
316
|
+
)
|
|
317
|
+
weight_sync.add_argument(
|
|
318
|
+
"--weight-format",
|
|
319
|
+
choices=("vllm", "hf", "lora"),
|
|
320
|
+
help="Weight sync payload format. Use 'lora' for adapter-only sync.",
|
|
321
|
+
)
|
|
322
|
+
weight_sync.add_argument(
|
|
323
|
+
"--no-poll",
|
|
324
|
+
action="store_true",
|
|
325
|
+
help="Print the request id without polling for completion.",
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
for command, job_parser in (
|
|
329
|
+
("fwd-bwd", fwd_bwd),
|
|
330
|
+
("step", step),
|
|
331
|
+
("load", load),
|
|
332
|
+
("generate", generate),
|
|
333
|
+
("weight-sync", weight_sync),
|
|
334
|
+
):
|
|
335
|
+
job_parser.prog = f"{prog} --job JOB_ID {command}"
|
|
336
|
+
job_parser.epilog = (
|
|
337
|
+
"Required global option: --job JOB_ID (alias: --job-id JOB_ID). "
|
|
338
|
+
"Place it before the subcommand."
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
download_log = subparsers.add_parser(
|
|
342
|
+
"download-log",
|
|
343
|
+
help="Download all log files for a Cortex Training job's experiment run.",
|
|
344
|
+
)
|
|
345
|
+
download_log.add_argument("job_id")
|
|
346
|
+
download_log.add_argument(
|
|
347
|
+
"--log-type",
|
|
348
|
+
choices=("execution", "stdout"),
|
|
349
|
+
default="execution",
|
|
350
|
+
help=(
|
|
351
|
+
"Log source to download: execution artifacts (default), or the "
|
|
352
|
+
"reconstructed head-pod stdout/stderr console."
|
|
353
|
+
),
|
|
354
|
+
)
|
|
355
|
+
download_log.add_argument(
|
|
356
|
+
"--output-dir",
|
|
357
|
+
dest="output_dir",
|
|
358
|
+
help=(
|
|
359
|
+
"Directory to write log files into, grouped as "
|
|
360
|
+
"<output_dir>/<sub_job_id>/<filename>. Created if missing. "
|
|
361
|
+
"Defaults to the current working directory."
|
|
362
|
+
),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
download_metrics = subparsers.add_parser(
|
|
366
|
+
"download-metrics",
|
|
367
|
+
help="Download and reconstruct GPU metrics for a Cortex Training job.",
|
|
368
|
+
)
|
|
369
|
+
download_metrics.add_argument("job_id")
|
|
370
|
+
download_metrics.add_argument(
|
|
371
|
+
"--output-dir",
|
|
372
|
+
dest="output_dir",
|
|
373
|
+
help=(
|
|
374
|
+
"Directory to write <sub_job_id>/gpu.jsonl files. Created if "
|
|
375
|
+
"missing. Defaults to the current working directory."
|
|
376
|
+
),
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
login = subparsers.add_parser(
|
|
380
|
+
"login",
|
|
381
|
+
usage="%(prog)s [-h] (config | --config config)",
|
|
382
|
+
help="Remember a Cortex Training config file for future commands.",
|
|
383
|
+
)
|
|
384
|
+
login_config = login.add_mutually_exclusive_group(required=True)
|
|
385
|
+
login_config.add_argument(
|
|
386
|
+
"login_config",
|
|
387
|
+
nargs="?",
|
|
388
|
+
metavar="config",
|
|
389
|
+
help="Path to the Cortex Training CLI config JSON file to remember.",
|
|
390
|
+
)
|
|
391
|
+
login_config.add_argument(
|
|
392
|
+
"--config",
|
|
393
|
+
dest="login_config_option",
|
|
394
|
+
metavar="config",
|
|
395
|
+
help="Alternative to the positional config path.",
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
if include_tui:
|
|
399
|
+
subparsers.add_parser("tui", help="Open the read-only Cortex Training log TUI.")
|
|
400
|
+
|
|
401
|
+
return parser
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _login_state_path() -> Path:
|
|
405
|
+
override = _env(_LOGIN_STATE_ENV)
|
|
406
|
+
if override:
|
|
407
|
+
return Path(override).expanduser()
|
|
408
|
+
config_home = _env("XDG_CONFIG_HOME")
|
|
409
|
+
base = Path(config_home).expanduser() if config_home else Path.home() / ".config"
|
|
410
|
+
return base / "cortex-training" / "login.json"
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _read_login_config_path() -> str | None:
|
|
414
|
+
path = _login_state_path()
|
|
415
|
+
if not path.exists():
|
|
416
|
+
return None
|
|
417
|
+
try:
|
|
418
|
+
parsed = json.loads(path.read_text(encoding="utf-8"))
|
|
419
|
+
except json.JSONDecodeError as exc:
|
|
420
|
+
raise ValueError(f"invalid cortex-training login state {path}: {exc}") from exc
|
|
421
|
+
if not isinstance(parsed, dict):
|
|
422
|
+
raise ValueError(f"invalid cortex-training login state {path}: expected object")
|
|
423
|
+
config_path = parsed.get("config_path")
|
|
424
|
+
if not isinstance(config_path, str) or not config_path:
|
|
425
|
+
raise ValueError(f"invalid cortex-training login state {path}: missing config_path")
|
|
426
|
+
return config_path
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _write_login_config_path(config_path: str) -> str:
|
|
430
|
+
path = Path(config_path).expanduser()
|
|
431
|
+
_load_config(str(path))
|
|
432
|
+
saved_path = str(path.resolve())
|
|
433
|
+
state_path = _login_state_path()
|
|
434
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
435
|
+
payload = {"config_path": saved_path}
|
|
436
|
+
state_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
|
437
|
+
return saved_path
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _load_config(path: str | None) -> dict[str, Any]:
|
|
441
|
+
if not path:
|
|
442
|
+
return {}
|
|
443
|
+
path_obj = Path(path).expanduser()
|
|
444
|
+
try:
|
|
445
|
+
parsed = json.loads(path_obj.read_text(encoding="utf-8"))
|
|
446
|
+
except json.JSONDecodeError as exc:
|
|
447
|
+
raise ValueError(f"invalid JSON in config {path}: {exc}") from exc
|
|
448
|
+
if not isinstance(parsed, dict):
|
|
449
|
+
raise ValueError("config JSON must be an object")
|
|
450
|
+
|
|
451
|
+
if "connection" in parsed:
|
|
452
|
+
connection = parsed["connection"]
|
|
453
|
+
if not isinstance(connection, dict):
|
|
454
|
+
raise ValueError("config connection must be an object")
|
|
455
|
+
parsed = connection
|
|
456
|
+
|
|
457
|
+
config: dict[str, Any] = {}
|
|
458
|
+
unknown = []
|
|
459
|
+
for key, value in parsed.items():
|
|
460
|
+
normalized = _CONFIG_ALIASES.get(key, key)
|
|
461
|
+
if normalized not in _CONFIG_KEYS:
|
|
462
|
+
unknown.append(key)
|
|
463
|
+
continue
|
|
464
|
+
config[normalized] = value
|
|
465
|
+
if unknown:
|
|
466
|
+
names = ", ".join(sorted(unknown))
|
|
467
|
+
raise ValueError(f"unknown config key(s): {names}")
|
|
468
|
+
return config
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _coalesce(*values: Any) -> Any:
|
|
472
|
+
for value in values:
|
|
473
|
+
if value is not None:
|
|
474
|
+
return value
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _config_str(config: dict[str, Any], key: str) -> str | None:
|
|
479
|
+
if key not in config or config[key] is None:
|
|
480
|
+
return None
|
|
481
|
+
if not isinstance(config[key], str):
|
|
482
|
+
raise ValueError(f"config {key} must be a string")
|
|
483
|
+
return config[key]
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _config_float(config: dict[str, Any], key: str) -> float | None:
|
|
487
|
+
if key not in config or config[key] is None:
|
|
488
|
+
return None
|
|
489
|
+
if isinstance(config[key], bool) or not isinstance(config[key], (int, float)):
|
|
490
|
+
raise ValueError(f"config {key} must be a number")
|
|
491
|
+
return float(config[key])
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _config_bool(config: dict[str, Any], key: str) -> bool | None:
|
|
495
|
+
if key not in config or config[key] is None:
|
|
496
|
+
return None
|
|
497
|
+
if not isinstance(config[key], bool):
|
|
498
|
+
raise ValueError(f"config {key} must be a boolean")
|
|
499
|
+
return config[key]
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _has_connection(
|
|
503
|
+
base_url: str | None,
|
|
504
|
+
host: str | None,
|
|
505
|
+
pat: str | None,
|
|
506
|
+
database: str | None,
|
|
507
|
+
) -> bool:
|
|
508
|
+
return bool(database and (base_url or (host and pat)))
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _select_config(args: argparse.Namespace, *, load_login: bool) -> dict[str, Any]:
|
|
512
|
+
if args.config:
|
|
513
|
+
return _load_config(args.config)
|
|
514
|
+
|
|
515
|
+
if not load_login:
|
|
516
|
+
return {}
|
|
517
|
+
|
|
518
|
+
base_url = _coalesce(args.base_url, _env("CORTEX_TRAINING_BASE_URL"))
|
|
519
|
+
host = _coalesce(args.host, _env("CORTEX_TRAINING_HOST", "SNOWFLAKE_HOST"))
|
|
520
|
+
pat = _coalesce(args.pat, _env("CORTEX_TRAINING_PAT", "SNOWFLAKE_PAT"))
|
|
521
|
+
database = _coalesce(
|
|
522
|
+
args.database,
|
|
523
|
+
_env("CORTEX_TRAINING_DATABASE", "SNOWFLAKE_DATABASE"),
|
|
524
|
+
)
|
|
525
|
+
if _has_connection(base_url, host, pat, database):
|
|
526
|
+
return {}
|
|
527
|
+
|
|
528
|
+
login_config = _read_login_config_path()
|
|
529
|
+
if login_config is None:
|
|
530
|
+
return {}
|
|
531
|
+
args.config = login_config
|
|
532
|
+
return _load_config(login_config)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _resolve_args(
|
|
536
|
+
args: argparse.Namespace,
|
|
537
|
+
*,
|
|
538
|
+
load_login: bool = True,
|
|
539
|
+
) -> argparse.Namespace:
|
|
540
|
+
config = _select_config(args, load_login=load_login)
|
|
541
|
+
args.base_url = _coalesce(
|
|
542
|
+
args.base_url,
|
|
543
|
+
_config_str(config, "base_url"),
|
|
544
|
+
_env("CORTEX_TRAINING_BASE_URL"),
|
|
545
|
+
)
|
|
546
|
+
args.host = _coalesce(
|
|
547
|
+
args.host,
|
|
548
|
+
_config_str(config, "host"),
|
|
549
|
+
_env("CORTEX_TRAINING_HOST", "SNOWFLAKE_HOST"),
|
|
550
|
+
)
|
|
551
|
+
args.pat = _coalesce(
|
|
552
|
+
args.pat,
|
|
553
|
+
_config_str(config, "pat"),
|
|
554
|
+
_env("CORTEX_TRAINING_PAT", "SNOWFLAKE_PAT"),
|
|
555
|
+
)
|
|
556
|
+
args.database = _coalesce(
|
|
557
|
+
args.database,
|
|
558
|
+
_config_str(config, "database"),
|
|
559
|
+
_env("CORTEX_TRAINING_DATABASE", "SNOWFLAKE_DATABASE"),
|
|
560
|
+
)
|
|
561
|
+
args.schema = _coalesce(
|
|
562
|
+
args.schema,
|
|
563
|
+
_config_str(config, "schema"),
|
|
564
|
+
_env("CORTEX_TRAINING_SCHEMA", "SNOWFLAKE_SCHEMA"),
|
|
565
|
+
"PUBLIC",
|
|
566
|
+
)
|
|
567
|
+
args.endpoint = _coalesce(
|
|
568
|
+
args.endpoint,
|
|
569
|
+
_config_str(config, "endpoint"),
|
|
570
|
+
_env("CORTEX_TRAINING_ENDPOINT"),
|
|
571
|
+
"cortex-training",
|
|
572
|
+
)
|
|
573
|
+
args.poll_interval = _coalesce(
|
|
574
|
+
args.poll_interval,
|
|
575
|
+
_config_float(config, "poll_interval"),
|
|
576
|
+
0.5,
|
|
577
|
+
)
|
|
578
|
+
args.poll_timeout = _coalesce(
|
|
579
|
+
args.poll_timeout,
|
|
580
|
+
_config_float(config, "poll_timeout"),
|
|
581
|
+
1800.0,
|
|
582
|
+
)
|
|
583
|
+
if not args.no_verify_ssl:
|
|
584
|
+
no_verify_ssl = _config_bool(config, "no_verify_ssl")
|
|
585
|
+
verify_ssl = _config_bool(config, "verify_ssl")
|
|
586
|
+
if no_verify_ssl is not None:
|
|
587
|
+
args.no_verify_ssl = no_verify_ssl
|
|
588
|
+
elif verify_ssl is not None:
|
|
589
|
+
args.no_verify_ssl = not verify_ssl
|
|
590
|
+
return args
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _normalize_connection_args(args: argparse.Namespace) -> argparse.Namespace:
|
|
594
|
+
if args.base_url is None or _has_url_scheme(args.base_url):
|
|
595
|
+
return args
|
|
596
|
+
if args.pat and args.host is None:
|
|
597
|
+
args.host = args.base_url
|
|
598
|
+
args.base_url = None
|
|
599
|
+
return args
|
|
600
|
+
raise ValueError(
|
|
601
|
+
"base_url must start with http:// or https://. For Snowflake PAT auth, use host instead of base_url."
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def parse_args(
|
|
606
|
+
argv: list[str] | None = None,
|
|
607
|
+
*,
|
|
608
|
+
prog: str = "cortex-training",
|
|
609
|
+
include_tui: bool = False,
|
|
610
|
+
) -> argparse.Namespace:
|
|
611
|
+
parser = build_parser(prog=prog, include_tui=include_tui)
|
|
612
|
+
args = parser.parse_args(argv)
|
|
613
|
+
if args.command == "login":
|
|
614
|
+
args.login_config = args.login_config or args.login_config_option
|
|
615
|
+
return args
|
|
616
|
+
|
|
617
|
+
dry_run = args.command == "submit" and args.dry_run
|
|
618
|
+
args = _resolve_args(args, load_login=not dry_run)
|
|
619
|
+
if dry_run:
|
|
620
|
+
return args
|
|
621
|
+
args = _normalize_connection_args(args)
|
|
622
|
+
if not args.database:
|
|
623
|
+
parser.error("provide --database or set CORTEX_TRAINING_DATABASE/SNOWFLAKE_DATABASE")
|
|
624
|
+
if args.base_url is None and (args.host is None or args.pat is None):
|
|
625
|
+
parser.error("provide --base-url for local/mock use, or both --host and --pat")
|
|
626
|
+
return args
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def build_client(args: argparse.Namespace, cortex_training_client_cls):
|
|
630
|
+
kwargs = {
|
|
631
|
+
"database": args.database,
|
|
632
|
+
"schema": args.schema,
|
|
633
|
+
"endpoint": args.endpoint,
|
|
634
|
+
"poll_interval": args.poll_interval,
|
|
635
|
+
"poll_timeout": args.poll_timeout,
|
|
636
|
+
}
|
|
637
|
+
if args.base_url:
|
|
638
|
+
return cortex_training_client_cls(base_url=args.base_url, **kwargs)
|
|
639
|
+
return cortex_training_client_cls.from_pat(
|
|
640
|
+
host=_normalize_host(args.host),
|
|
641
|
+
pat=args.pat,
|
|
642
|
+
verify_ssl=not args.no_verify_ssl,
|
|
643
|
+
**kwargs,
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _read_json_object(path: str, stdin: TextIO) -> dict[str, Any]:
|
|
648
|
+
if path == "-":
|
|
649
|
+
raw = stdin.read()
|
|
650
|
+
else:
|
|
651
|
+
raw = Path(path).read_text(encoding="utf-8")
|
|
652
|
+
try:
|
|
653
|
+
parsed = json.loads(raw)
|
|
654
|
+
except json.JSONDecodeError as exc:
|
|
655
|
+
raise ValueError(f"invalid JSON in {path}: {exc}") from exc
|
|
656
|
+
if not isinstance(parsed, dict):
|
|
657
|
+
raise ValueError("job JSON must be an object")
|
|
658
|
+
return parsed
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _validate_create_job_body(body: dict[str, Any]) -> None:
|
|
662
|
+
sub_job_configs = body.get("sub_job_configs")
|
|
663
|
+
if not isinstance(sub_job_configs, list) or not sub_job_configs:
|
|
664
|
+
raise ValueError("job JSON must contain a non-empty sub_job_configs list")
|
|
665
|
+
# Mirrors client.create_job_from_body; submit --dry-run never builds a client.
|
|
666
|
+
training_sub_jobs = sum(
|
|
667
|
+
1
|
|
668
|
+
for cfg in sub_job_configs
|
|
669
|
+
if isinstance(cfg, dict) and str(cfg.get("job_type") or "").strip().lower() == "training"
|
|
670
|
+
)
|
|
671
|
+
if training_sub_jobs > 1:
|
|
672
|
+
raise ValueError("at most one training sub-job is supported per job")
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _print_json(value: Any, stdout: TextIO, *, compact: bool) -> None:
|
|
676
|
+
if compact:
|
|
677
|
+
json.dump(value, stdout, separators=(",", ":"), sort_keys=True)
|
|
678
|
+
else:
|
|
679
|
+
json.dump(value, stdout, indent=2, sort_keys=True)
|
|
680
|
+
stdout.write("\n")
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _format_error(exc: BaseException) -> str:
|
|
684
|
+
response = getattr(exc, "response", None)
|
|
685
|
+
if response is None:
|
|
686
|
+
return str(exc)
|
|
687
|
+
|
|
688
|
+
parts = [str(exc)]
|
|
689
|
+
request_id = getattr(response, "headers", {}).get("x-snowflake-request-id")
|
|
690
|
+
if request_id:
|
|
691
|
+
parts.append(f"snowflake request id: {request_id}")
|
|
692
|
+
|
|
693
|
+
body = (getattr(response, "text", "") or "").strip()
|
|
694
|
+
if body:
|
|
695
|
+
if len(body) > 4000:
|
|
696
|
+
body = body[:4000] + "...<truncated>"
|
|
697
|
+
parts.append(f"response body: {body}")
|
|
698
|
+
return "\n".join(parts)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _cmd_submit(
|
|
702
|
+
args: argparse.Namespace,
|
|
703
|
+
client,
|
|
704
|
+
stdout: TextIO,
|
|
705
|
+
stdin: TextIO,
|
|
706
|
+
) -> int:
|
|
707
|
+
body = _read_json_object(args.json_file, stdin)
|
|
708
|
+
_validate_create_job_body(body)
|
|
709
|
+
if args.job_id is not None:
|
|
710
|
+
body = dict(body)
|
|
711
|
+
body["job_id"] = args.job_id
|
|
712
|
+
|
|
713
|
+
if args.dry_run:
|
|
714
|
+
_print_json(body, stdout, compact=args.compact)
|
|
715
|
+
return 0
|
|
716
|
+
|
|
717
|
+
response = client.create_job_from_body(body)
|
|
718
|
+
if args.wait:
|
|
719
|
+
job_id = response.get("job_id") or body.get("job_id")
|
|
720
|
+
if not job_id:
|
|
721
|
+
raise ValueError("submit --wait requires the create response to include job_id")
|
|
722
|
+
response = client.wait_for_job(str(job_id))
|
|
723
|
+
_print_json(response, stdout, compact=args.compact)
|
|
724
|
+
return 0
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _cmd_login(args: argparse.Namespace, stdout: TextIO) -> int:
|
|
728
|
+
config_path = _write_login_config_path(args.login_config)
|
|
729
|
+
_print_json(
|
|
730
|
+
{"config_path": config_path, "logged_in": True},
|
|
731
|
+
stdout,
|
|
732
|
+
compact=args.compact,
|
|
733
|
+
)
|
|
734
|
+
return 0
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _json_bool(value: Any, name: str) -> bool:
|
|
738
|
+
if not isinstance(value, bool):
|
|
739
|
+
raise ValueError(f"{name} must be a boolean")
|
|
740
|
+
return value
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _cmd_fwd_bwd(
|
|
744
|
+
args: argparse.Namespace,
|
|
745
|
+
client,
|
|
746
|
+
stdout: TextIO,
|
|
747
|
+
stdin: TextIO,
|
|
748
|
+
) -> int:
|
|
749
|
+
if not args.job:
|
|
750
|
+
raise ValueError("provide --job JOB_ID for fwd-bwd")
|
|
751
|
+
|
|
752
|
+
spec = _read_json_object(args.json_file, stdin)
|
|
753
|
+
poll = _json_bool(spec.get("poll", True), "fwd-bwd poll")
|
|
754
|
+
build_payload = _load_forward_backward_payload_builder()
|
|
755
|
+
payload = build_payload(spec)
|
|
756
|
+
|
|
757
|
+
request_id = client.forward_backward(args.job, payload)
|
|
758
|
+
response = {
|
|
759
|
+
"job_id": args.job,
|
|
760
|
+
"payload_size_bytes": len(payload),
|
|
761
|
+
"request_id": request_id,
|
|
762
|
+
}
|
|
763
|
+
if poll:
|
|
764
|
+
response["result"] = client.poll_request(args.job, request_id)
|
|
765
|
+
|
|
766
|
+
_print_json(response, stdout, compact=args.compact)
|
|
767
|
+
return 0
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _cmd_step(args: argparse.Namespace, client, stdout: TextIO) -> int:
|
|
771
|
+
if not args.job:
|
|
772
|
+
raise ValueError("provide --job-id JOB_ID for step")
|
|
773
|
+
|
|
774
|
+
request_id = client.step(args.job, learning_rate=args.lr)
|
|
775
|
+
response = {
|
|
776
|
+
"job_id": args.job,
|
|
777
|
+
"learning_rate": args.lr,
|
|
778
|
+
"request_id": request_id,
|
|
779
|
+
"result": client.poll_request(args.job, request_id),
|
|
780
|
+
}
|
|
781
|
+
_print_json(response, stdout, compact=args.compact)
|
|
782
|
+
return 0
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _cmd_load(args: argparse.Namespace, client, stdout: TextIO) -> int:
|
|
786
|
+
if not args.job:
|
|
787
|
+
raise ValueError("provide --job-id JOB_ID for load")
|
|
788
|
+
|
|
789
|
+
request_id = client.load(
|
|
790
|
+
args.job,
|
|
791
|
+
checkpoint_id=args.checkpoint_id,
|
|
792
|
+
source_job_id=args.source_job_id,
|
|
793
|
+
target_sub_job_id=args.target_sub_job_id,
|
|
794
|
+
)
|
|
795
|
+
response = {
|
|
796
|
+
"checkpoint_id": args.checkpoint_id,
|
|
797
|
+
"job_id": args.job,
|
|
798
|
+
"request_id": request_id,
|
|
799
|
+
}
|
|
800
|
+
if args.source_job_id is not None:
|
|
801
|
+
response["source_job_id"] = args.source_job_id
|
|
802
|
+
if args.target_sub_job_id is not None:
|
|
803
|
+
response["target_sub_job_id"] = args.target_sub_job_id
|
|
804
|
+
if not args.no_poll:
|
|
805
|
+
response["result"] = client.poll_request(args.job, request_id)
|
|
806
|
+
|
|
807
|
+
_print_json(response, stdout, compact=args.compact)
|
|
808
|
+
return 0
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _cmd_generate(
|
|
812
|
+
args: argparse.Namespace,
|
|
813
|
+
client,
|
|
814
|
+
stdout: TextIO,
|
|
815
|
+
stdin: TextIO,
|
|
816
|
+
) -> int:
|
|
817
|
+
if not args.job:
|
|
818
|
+
raise ValueError("provide --job-id JOB_ID for generate")
|
|
819
|
+
|
|
820
|
+
spec = _read_json_object(args.json_file, stdin)
|
|
821
|
+
payload = spec.get("payload", spec)
|
|
822
|
+
if not isinstance(payload, dict):
|
|
823
|
+
raise ValueError("generate payload must be an object")
|
|
824
|
+
|
|
825
|
+
poll = _json_bool(spec.get("poll", True), "generate poll")
|
|
826
|
+
prompts = payload.get("prompts")
|
|
827
|
+
if not isinstance(prompts, list) or not prompts:
|
|
828
|
+
raise ValueError("generate JSON must contain a non-empty prompts list")
|
|
829
|
+
|
|
830
|
+
sampling_params = payload.get("sampling_params")
|
|
831
|
+
if sampling_params is not None:
|
|
832
|
+
if isinstance(sampling_params, list):
|
|
833
|
+
if len(sampling_params) != len(prompts):
|
|
834
|
+
raise ValueError("generate sampling_params list length must match prompts length")
|
|
835
|
+
if any(item is not None and not isinstance(item, dict) for item in sampling_params):
|
|
836
|
+
raise ValueError("generate sampling_params list items must be objects or null")
|
|
837
|
+
elif not isinstance(sampling_params, dict):
|
|
838
|
+
raise ValueError("generate sampling_params must be an object or list")
|
|
839
|
+
|
|
840
|
+
strict = payload.get("strict")
|
|
841
|
+
if strict is not None:
|
|
842
|
+
strict = _json_bool(strict, "generate strict")
|
|
843
|
+
|
|
844
|
+
request_id = client.generate(
|
|
845
|
+
args.job,
|
|
846
|
+
prompts=prompts,
|
|
847
|
+
sampling_params=sampling_params,
|
|
848
|
+
routing_key=payload.get("routing_key"),
|
|
849
|
+
strict=strict,
|
|
850
|
+
)
|
|
851
|
+
response = {
|
|
852
|
+
"job_id": args.job,
|
|
853
|
+
"prompt_count": len(prompts),
|
|
854
|
+
"request_id": request_id,
|
|
855
|
+
}
|
|
856
|
+
if poll:
|
|
857
|
+
response["result"] = client.poll_request(args.job, request_id)
|
|
858
|
+
|
|
859
|
+
_print_json(response, stdout, compact=args.compact)
|
|
860
|
+
return 0
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _cmd_weight_sync(args: argparse.Namespace, client, stdout: TextIO) -> int:
|
|
864
|
+
if not args.job:
|
|
865
|
+
raise ValueError("provide --job-id JOB_ID for weight-sync")
|
|
866
|
+
|
|
867
|
+
source_sub_job_id = args.source_sub_job_id or f"{args.job}:training:0"
|
|
868
|
+
target_sub_job_ids = args.target_sub_job_ids or [f"{args.job}:sampling:0"]
|
|
869
|
+
request_id = client.weight_sync(
|
|
870
|
+
args.job,
|
|
871
|
+
source_sub_job_id=source_sub_job_id,
|
|
872
|
+
target_sub_job_ids=target_sub_job_ids,
|
|
873
|
+
weight_format=args.weight_format,
|
|
874
|
+
sub_job_id=args.operation_sub_job_id,
|
|
875
|
+
sub_job_type=args.operation_sub_job_type,
|
|
876
|
+
)
|
|
877
|
+
response = {
|
|
878
|
+
"job_id": args.job,
|
|
879
|
+
"request_id": request_id,
|
|
880
|
+
"source_sub_job_id": source_sub_job_id,
|
|
881
|
+
"target_sub_job_ids": target_sub_job_ids,
|
|
882
|
+
}
|
|
883
|
+
if args.weight_format is not None:
|
|
884
|
+
response["weight_format"] = args.weight_format
|
|
885
|
+
if not args.no_poll:
|
|
886
|
+
response["result"] = client.poll_request(args.job, request_id)
|
|
887
|
+
|
|
888
|
+
_print_json(response, stdout, compact=args.compact)
|
|
889
|
+
return 0
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _cmd_download_log(args: argparse.Namespace, client, stdout: TextIO) -> int:
|
|
893
|
+
out_dir = Path(args.output_dir).expanduser() if args.output_dir else Path.cwd()
|
|
894
|
+
if args.log_type == "stdout":
|
|
895
|
+
logs = client.download_stdout_logs(args.job_id, out_dir)
|
|
896
|
+
_print_json(
|
|
897
|
+
{"job_id": args.job_id, "logs": logs},
|
|
898
|
+
stdout,
|
|
899
|
+
compact=args.compact,
|
|
900
|
+
)
|
|
901
|
+
return 0
|
|
902
|
+
|
|
903
|
+
logs = client.fetch_execution_logs(args.job_id)
|
|
904
|
+
saved = []
|
|
905
|
+
for log in logs:
|
|
906
|
+
file_path = out_dir / (log["sub_job_id"] or "unknown") / log["filename"]
|
|
907
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
908
|
+
file_path.write_text(log["content"], encoding="utf-8")
|
|
909
|
+
saved.append(
|
|
910
|
+
{
|
|
911
|
+
"sub_job_id": log["sub_job_id"],
|
|
912
|
+
"filename": log["filename"],
|
|
913
|
+
"artifact_uri": log["artifact_uri"],
|
|
914
|
+
"saved_path": str(file_path),
|
|
915
|
+
}
|
|
916
|
+
)
|
|
917
|
+
_print_json({"job_id": args.job_id, "logs": saved}, stdout, compact=args.compact)
|
|
918
|
+
return 0
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _cmd_download_metrics(
|
|
922
|
+
args: argparse.Namespace, client, stdout: TextIO
|
|
923
|
+
) -> int:
|
|
924
|
+
out_dir = Path(args.output_dir).expanduser() if args.output_dir else Path.cwd()
|
|
925
|
+
metrics = client.download_metrics(args.job_id, out_dir)
|
|
926
|
+
_print_json(
|
|
927
|
+
{"job_id": args.job_id, "metrics": metrics},
|
|
928
|
+
stdout,
|
|
929
|
+
compact=args.compact,
|
|
930
|
+
)
|
|
931
|
+
return 0
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _run(
|
|
935
|
+
args: argparse.Namespace,
|
|
936
|
+
client_factory: Callable[[argparse.Namespace], Any],
|
|
937
|
+
stdout: TextIO,
|
|
938
|
+
stdin: TextIO,
|
|
939
|
+
) -> int:
|
|
940
|
+
if args.command == "login":
|
|
941
|
+
return _cmd_login(args, stdout)
|
|
942
|
+
|
|
943
|
+
if args.command == "submit" and args.dry_run:
|
|
944
|
+
return _cmd_submit(args, None, stdout, stdin)
|
|
945
|
+
|
|
946
|
+
client = client_factory(args)
|
|
947
|
+
if args.command == "submit":
|
|
948
|
+
return _cmd_submit(args, client, stdout, stdin)
|
|
949
|
+
if args.command == "get":
|
|
950
|
+
_print_json(client.get_job(args.job_id), stdout, compact=args.compact)
|
|
951
|
+
return 0
|
|
952
|
+
if args.command == "checkpoints":
|
|
953
|
+
_print_json(
|
|
954
|
+
{"checkpoints": client.list_checkpoints(args.job_id)},
|
|
955
|
+
stdout,
|
|
956
|
+
compact=args.compact,
|
|
957
|
+
)
|
|
958
|
+
return 0
|
|
959
|
+
if args.command == "list":
|
|
960
|
+
jobs = client.list_jobs(status=args.status)
|
|
961
|
+
_print_json({"jobs": _jobs_latest_last(jobs)}, stdout, compact=args.compact)
|
|
962
|
+
return 0
|
|
963
|
+
if args.command == "capacity":
|
|
964
|
+
if args.hardware is not None:
|
|
965
|
+
capacity = client.get_capacity(hardware=args.hardware)
|
|
966
|
+
else:
|
|
967
|
+
capacity = {
|
|
968
|
+
"capacity_by_hardware": {
|
|
969
|
+
hardware: client.get_capacity(hardware=hardware)
|
|
970
|
+
for hardware in _hardware_choices()
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
_print_json(capacity, stdout, compact=args.compact)
|
|
974
|
+
return 0
|
|
975
|
+
if args.command == "cancel":
|
|
976
|
+
client.cancel_job(args.job_id)
|
|
977
|
+
_print_json(
|
|
978
|
+
{"cancelled": True, "job_id": args.job_id},
|
|
979
|
+
stdout,
|
|
980
|
+
compact=args.compact,
|
|
981
|
+
)
|
|
982
|
+
return 0
|
|
983
|
+
if args.command == "wait":
|
|
984
|
+
_print_json(client.wait_for_job(args.job_id), stdout, compact=args.compact)
|
|
985
|
+
return 0
|
|
986
|
+
if args.command == "fwd-bwd":
|
|
987
|
+
return _cmd_fwd_bwd(args, client, stdout, stdin)
|
|
988
|
+
if args.command == "step":
|
|
989
|
+
return _cmd_step(args, client, stdout)
|
|
990
|
+
if args.command == "load":
|
|
991
|
+
return _cmd_load(args, client, stdout)
|
|
992
|
+
if args.command == "generate":
|
|
993
|
+
return _cmd_generate(args, client, stdout, stdin)
|
|
994
|
+
if args.command == "weight-sync":
|
|
995
|
+
return _cmd_weight_sync(args, client, stdout)
|
|
996
|
+
if args.command == "download-log":
|
|
997
|
+
return _cmd_download_log(args, client, stdout)
|
|
998
|
+
if args.command == "download-metrics":
|
|
999
|
+
return _cmd_download_metrics(args, client, stdout)
|
|
1000
|
+
raise ValueError(f"unknown command: {args.command}")
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def main(
|
|
1004
|
+
argv: list[str] | None = None,
|
|
1005
|
+
*,
|
|
1006
|
+
prog: str = "cortex-training",
|
|
1007
|
+
include_tui: bool = False,
|
|
1008
|
+
client_factory: Callable[[argparse.Namespace], Any] | None = None,
|
|
1009
|
+
stdout: TextIO | None = None,
|
|
1010
|
+
stderr: TextIO | None = None,
|
|
1011
|
+
stdin: TextIO | None = None,
|
|
1012
|
+
) -> int:
|
|
1013
|
+
stdout = stdout or sys.stdout
|
|
1014
|
+
stderr = stderr or sys.stderr
|
|
1015
|
+
stdin = stdin or sys.stdin
|
|
1016
|
+
|
|
1017
|
+
try:
|
|
1018
|
+
args = parse_args(argv, prog=prog, include_tui=include_tui)
|
|
1019
|
+
dry_run = args.command == "submit" and args.dry_run
|
|
1020
|
+
no_client = dry_run or args.command == "login"
|
|
1021
|
+
if client_factory is None and not no_client:
|
|
1022
|
+
cortex_training_client_cls = _load_cortex_training_client_class()
|
|
1023
|
+
|
|
1024
|
+
def default_client_factory(parsed_args: argparse.Namespace) -> Any:
|
|
1025
|
+
return build_client(parsed_args, cortex_training_client_cls)
|
|
1026
|
+
|
|
1027
|
+
client_factory = default_client_factory
|
|
1028
|
+
elif client_factory is None:
|
|
1029
|
+
|
|
1030
|
+
def empty_client_factory(_parsed_args: argparse.Namespace) -> None:
|
|
1031
|
+
return None
|
|
1032
|
+
|
|
1033
|
+
client_factory = empty_client_factory
|
|
1034
|
+
return _run(args, client_factory, stdout, stdin)
|
|
1035
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
1036
|
+
print(f"error: {_format_error(exc)}", file=stderr)
|
|
1037
|
+
return 1
|
|
1038
|
+
except ModuleNotFoundError as exc:
|
|
1039
|
+
missing = exc.name or "a required package"
|
|
1040
|
+
print(
|
|
1041
|
+
f"error: missing Python dependency '{missing}'. "
|
|
1042
|
+
"Install the missing package, or install this repo first, "
|
|
1043
|
+
"for example: python3 -m pip install -e .",
|
|
1044
|
+
file=stderr,
|
|
1045
|
+
)
|
|
1046
|
+
return 1
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
if __name__ == "__main__":
|
|
1050
|
+
raise SystemExit(main())
|