gpushift 0.1.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.
gpushift/cli.py ADDED
@@ -0,0 +1,1100 @@
1
+ """gpushift command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import re
9
+ import shlex
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ from pathlib import Path
15
+
16
+ from . import __version__, autostart, config, control, daemon, db, gpus, paths
17
+ from ._proc import NO_WINDOW
18
+ from .scheduler import PRESETS, PresetError, preset_requirements
19
+
20
+
21
+ class CLIError(Exception):
22
+ pass
23
+
24
+
25
+ # ---------------------------------------------------------------- formatting
26
+
27
+
28
+ def parse_duration(s: str) -> int:
29
+ """'40m', '1h30m', '90s', '2h', '1.5h'; a bare number means minutes."""
30
+ s = s.strip().lower()
31
+ if re.fullmatch(r"\d+(\.\d+)?", s):
32
+ return int(float(s) * 60)
33
+ total = 0.0
34
+ pos = 0
35
+ for m in re.finditer(r"(\d+(?:\.\d+)?)\s*([dhms])", s):
36
+ if m.start() != pos and s[pos : m.start()].strip():
37
+ raise CLIError(f"cannot parse duration {s!r} (examples: 40m, 1h30m, 90s)")
38
+ total += float(m.group(1)) * {"d": 86400, "h": 3600, "m": 60, "s": 1}[m.group(2)]
39
+ pos = m.end()
40
+ if pos == 0 or s[pos:].strip():
41
+ raise CLIError(f"cannot parse duration {s!r} (examples: 40m, 1h30m, 90s)")
42
+ return int(total)
43
+
44
+
45
+ def parse_size(s: str) -> int:
46
+ """'6G', '6GB', '500M', '1.5g'; a bare number means GB. Returns MiB."""
47
+ m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([gm]i?b?)?\s*", s.lower())
48
+ if not m:
49
+ raise CLIError(f"cannot parse memory size {s!r} (examples: 6G, 500M)")
50
+ n = float(m.group(1))
51
+ return int(n if (m.group(2) or "g").startswith("m") else n * 1024)
52
+
53
+
54
+ def fmt_dur(sec: float | None) -> str:
55
+ if sec is None:
56
+ return "?"
57
+ sec = int(max(sec, 0))
58
+ if sec < 60:
59
+ return f"{sec}s"
60
+ m, _ = divmod(sec, 60)
61
+ h, m = divmod(m, 60)
62
+ d, h = divmod(h, 24)
63
+ if d:
64
+ return f"{d}d{h:02d}h"
65
+ if h:
66
+ return f"{h}h{m:02d}m"
67
+ return f"{m}m"
68
+
69
+
70
+ def fmt_clock(ts: float | None) -> str:
71
+ if ts is None:
72
+ return "?"
73
+ lt = time.localtime(ts)
74
+ if time.strftime("%Y%m%d", lt) == time.strftime("%Y%m%d"):
75
+ return time.strftime("%H:%M", lt)
76
+ return time.strftime("%a %H:%M", lt)
77
+
78
+
79
+ def fmt_start(ts: float | None, mode: str) -> str:
80
+ if mode != "normal":
81
+ return "paused"
82
+ if ts is None:
83
+ return "unknown"
84
+ rel = ts - time.time()
85
+ if rel < 30:
86
+ return "now"
87
+ return f"~{fmt_dur(rel)} ({fmt_clock(ts)})"
88
+
89
+
90
+ def iso(ts: float | None) -> str | None:
91
+ return None if ts is None else time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime(ts))
92
+
93
+
94
+ def trunc(s: str, n: int) -> str:
95
+ return s if len(s) <= n else s[: n - 3] + "..."
96
+
97
+
98
+ def job_public(con, j: dict) -> dict:
99
+ runtime = control.ran(j)
100
+ return {
101
+ "id": j["id"],
102
+ "state": j["state"],
103
+ "preset": j["preset"],
104
+ "n_gpus": j["n_gpus"],
105
+ "slots": j["slots"],
106
+ "gpus": j["gpus"] or [],
107
+ "tag": j["tag"],
108
+ "project": j["project"],
109
+ "session": j["session"],
110
+ "command": j["command"],
111
+ "cwd": j["cwd"],
112
+ "submitted_at": iso(j["submitted_at"]),
113
+ "started_at": iso(j["started_at"]),
114
+ "ended_at": iso(j["ended_at"]),
115
+ "frozen_at": iso(j["frozen_at"]),
116
+ "runtime_s": round(runtime),
117
+ "exit_code": j["exit_code"],
118
+ "eta_s": j["eta_seconds"],
119
+ "expected_duration_s": (lambda d: None if d is None else round(d))(control.expected_duration(con, j)),
120
+ "resumable": control.is_resumable(j),
121
+ "restartable": bool(j["restartable"]),
122
+ "vram_mb": j["vram_mb"],
123
+ "peak_vram_mb": j["peak_vram_mb"],
124
+ "evictions": j["evictions"],
125
+ "resume": bool(j["resume"]),
126
+ "after": j.get("after_ids") or [],
127
+ "log": str(paths.log_file(j["id"])),
128
+ "note": j["note"],
129
+ }
130
+
131
+
132
+ # ---------------------------------------------------------------- context
133
+
134
+
135
+ def load_cfg() -> config.Config:
136
+ try:
137
+ return config.load()
138
+ except config.ConfigError as e:
139
+ raise CLIError(f"invalid config: {e}") from e
140
+
141
+
142
+ def project_name(cwd: str) -> str:
143
+ try:
144
+ r = subprocess.run(
145
+ ["git", "rev-parse", "--show-toplevel"],
146
+ cwd=cwd,
147
+ capture_output=True,
148
+ text=True,
149
+ timeout=3,
150
+ creationflags=NO_WINDOW,
151
+ )
152
+ if r.returncode == 0 and r.stdout.strip():
153
+ return Path(r.stdout.strip()).name
154
+ except (OSError, subprocess.TimeoutExpired):
155
+ pass
156
+ return Path(cwd).name or cwd
157
+
158
+
159
+ def git_bash() -> str | None:
160
+ """On Windows, the bash.exe of the Git Bash (MSYS2) session gpushift was called
161
+ from, or None. Never WSL's bash in System32, which is a different system."""
162
+ if os.name != "nt" or not os.environ.get("MSYSTEM"):
163
+ return None
164
+ exe = shutil.which("bash")
165
+ if not exe or Path(exe).parent.name.lower() in ("system32", "windowsapps"):
166
+ return None
167
+ return exe
168
+
169
+
170
+ def windows_style(command: str) -> bool:
171
+ """A shell string written for cmd.exe: it has backslash paths such as
172
+ `.venv\\Scripts\\python.exe`, which bash would read as escapes."""
173
+ return bool(re.search(r"[\w.]\\[\w.]", command))
174
+
175
+
176
+ def check_program_name(cmd: list[str]) -> None:
177
+ """Catch a command that was half-quoted, e.g. `"python run.py a 1" --flag`:
178
+ the quoted part would be taken as the name of one program."""
179
+ prog = cmd[0]
180
+ if len(cmd) < 2 or not re.search(r"\s", prog) or os.path.exists(prog) or shutil.which(prog):
181
+ return
182
+ raise CLIError(
183
+ f"the program to run is {prog!r}, which contains spaces, so gpushift would look for a program with that whole "
184
+ "name. Pass the command as separate words (gpushift submit -- python run.py a 1 --flag) or quote all of it "
185
+ 'as one string (gpushift submit -- "python run.py a 1 --flag").'
186
+ )
187
+
188
+
189
+ def check_runnable(cmd: list[str], cwd: str) -> None:
190
+ """Fail at submit time, not hours later, when the program or the Python
191
+ script does not exist."""
192
+ prog = cmd[0]
193
+ if not (os.path.exists(os.path.join(cwd, prog)) or shutil.which(prog)):
194
+ raise CLIError(f"program not found: {prog!r} (not on PATH and not a file in {cwd})")
195
+ if not re.fullmatch(r"(python(\d+(\.\d+)?)?|py|pythonw)(\.exe)?", Path(prog).name, re.I):
196
+ return
197
+ args = iter(cmd[1:])
198
+ for a in args:
199
+ if a in ("-m", "-c", "-"):
200
+ return
201
+ if a in ("-W", "-X"): # options that take a value
202
+ next(args, None)
203
+ continue
204
+ if a.startswith("-"):
205
+ continue
206
+ if a.endswith(".py") and not os.path.exists(os.path.join(cwd, a)):
207
+ raise CLIError(f"script not found: {a!r} (relative to {cwd})")
208
+ return
209
+
210
+
211
+ def ensure_daemon(quiet: bool = False) -> bool:
212
+ if daemon.is_running():
213
+ return True
214
+ ok = daemon.start_background()
215
+ if not quiet:
216
+ print(
217
+ "started the gpushift daemon"
218
+ if ok
219
+ else "warning: could not start the gpushift daemon; run `gpushift doctor`",
220
+ file=sys.stderr,
221
+ )
222
+ return ok
223
+
224
+
225
+ def get_job_or_fail(con, job_id: int) -> dict:
226
+ j = db.get_job(con, job_id)
227
+ if j is None:
228
+ raise CLIError(f"job {job_id} not found")
229
+ return j
230
+
231
+
232
+ def _print_json(obj) -> None:
233
+ print(json.dumps(obj, indent=2))
234
+
235
+
236
+ # ---------------------------------------------------------------- commands
237
+
238
+
239
+ def cmd_submit(a) -> int:
240
+ cmd = list(a.cmd or [])
241
+ if cmd and cmd[0] == "--":
242
+ cmd = cmd[1:]
243
+ if not cmd:
244
+ raise CLIError("nothing to run. Usage: gpushift submit --one -- python train.py")
245
+ cfg = load_cfg()
246
+ preset = a.preset or ("one" if cfg.gpus else "cpu")
247
+ try:
248
+ req = preset_requirements(preset, len(cfg.gpus), cfg.slots, gpus=a.gpus, slots=a.slots)
249
+ except PresetError as e:
250
+ raise CLIError(str(e)) from e
251
+ if preset == "light" and cfg.shared_gpu is None:
252
+ raise CLIError("--light needs a shared GPU, but none is configured")
253
+
254
+ cwd = os.getcwd()
255
+ check_program_name(cmd)
256
+ shell = len(cmd) == 1 and bool(re.search(r"[\s&|<>;]", cmd[0]))
257
+ if not shell:
258
+ check_runnable(cmd, cwd)
259
+ command = cmd[0] if len(cmd) == 1 else (subprocess.list2cmdline(cmd) if os.name == "nt" else shlex.join(cmd))
260
+ if shell and not windows_style(cmd[0]) and (bash := git_bash()):
261
+ # Written for the Git Bash it was submitted from (/c/... paths, cp,
262
+ # 2>&1); cmd.exe would misread it, so run it in that bash.
263
+ cmd, shell = [bash, "-c", cmd[0]], False
264
+ project = project_name(cwd)
265
+ con = db.connect()
266
+ after = parse_after(con, a.after)
267
+ with db.tx(con):
268
+ pos = db.front_position(con) if a.priority else db.back_position(con)
269
+ jid = db.insert_job(
270
+ con,
271
+ state="queued",
272
+ prio=int(a.priority),
273
+ preset=preset,
274
+ n_gpus=req["n_gpus"],
275
+ slots=req["slots"],
276
+ exclusive=int(req["exclusive"]),
277
+ shared=int(req["shared"]),
278
+ position=pos,
279
+ argv=cmd,
280
+ command=command,
281
+ shell=int(shell),
282
+ cwd=cwd,
283
+ env=control.filtered_env(),
284
+ tag=a.tag or project,
285
+ project=project,
286
+ session=os.environ.get("CLAUDE_CODE_SESSION_ID"),
287
+ eta_seconds=parse_duration(a.eta) if a.eta else None,
288
+ resumable=int(a.resumable),
289
+ restartable=int(a.restartable),
290
+ vram_mb=parse_size(a.vram) if a.vram else None,
291
+ after_ids=after or None,
292
+ )
293
+ paths.job_dir(jid).mkdir(parents=True, exist_ok=True)
294
+ if not a.no_start:
295
+ ensure_daemon(quiet=a.json)
296
+
297
+ queue, starts = control.queue_and_starts(con, cfg)
298
+ position = next((i + 1 for i, q in enumerate(queue) if q["id"] == jid), None)
299
+ est = starts.get(jid)
300
+ mode = db.get_mode(con)
301
+ if a.json:
302
+ _print_json(
303
+ {
304
+ "id": jid,
305
+ "preset": preset,
306
+ "position": position,
307
+ "est_start": iso(est),
308
+ "mode": mode,
309
+ "log": str(paths.log_file(jid)),
310
+ }
311
+ )
312
+ return 0
313
+ desc = {
314
+ "light": "shared GPU",
315
+ "one": "1 GPU",
316
+ "two": f"{req['n_gpus']} GPUs",
317
+ "exclusive": "whole machine",
318
+ "cpu": "CPU only",
319
+ }[preset]
320
+ print(
321
+ f"job {jid} queued ({preset}: {desc}, {req['slots']} slot{'s' if req['slots'] != 1 else ''}) tag={a.tag or project}"
322
+ )
323
+ waits = next((q["waiting_for"] for q in queue if q["id"] == jid), [])
324
+ if waits:
325
+ print(f"position {position} of {len(queue)}; starts after job {', '.join(map(str, waits))} has finished")
326
+ else:
327
+ print(f"position {position} of {len(queue)}, estimated start: {fmt_start(est, mode)}")
328
+ if mode != "normal":
329
+ print("note: the queue is paused (free mode); it will start after `gpushift resume`")
330
+ print(f"logs: gpushift logs {jid} -f")
331
+ return 0
332
+
333
+
334
+ def parse_after(con, values: list[str] | None) -> list[int]:
335
+ """--after 12 --after 13,14 -> [12, 13, 14], checking that each job exists
336
+ and can still finish successfully."""
337
+ ids: list[int] = []
338
+ for v in values or []:
339
+ for part in v.replace(" ", "").split(","):
340
+ if not part:
341
+ continue
342
+ if not part.isdigit():
343
+ raise CLIError(f"--after takes job ids, e.g. --after 12 or --after 12,13 (got {part!r})")
344
+ ids.append(int(part))
345
+ for i in dict.fromkeys(ids):
346
+ j = db.get_job(con, i)
347
+ if j is None:
348
+ raise CLIError(f"--after {i}: there is no job {i}")
349
+ if j["state"] in ("failed", "cancelled"):
350
+ raise CLIError(f"--after {i}: job {i} {j['state']}, so this job would never run")
351
+ return list(dict.fromkeys(ids))
352
+
353
+
354
+ def timeline(con, running: list[dict], queue: list[dict], placed: dict) -> dict:
355
+ """What runs on which GPU and when: running jobs (end = expected end or
356
+ None) and the queued jobs the forecast can place. Times are unix seconds."""
357
+ now = time.time()
358
+ items = []
359
+ for j in running:
360
+ left = control.time_left(con, j, now)
361
+ items.append(
362
+ {"id": j["id"], "gpus": j["gpus"] or [], "start": j["started_at"], "end": None if left is None else now + left,
363
+ "state": j["state"]}
364
+ ) # fmt: skip
365
+ for j in queue:
366
+ if j["id"] in placed:
367
+ start, gpus, end = placed[j["id"]]
368
+ items.append({"id": j["id"], "gpus": gpus, "start": start, "end": end, "state": "queued"})
369
+ unplaced = [j["id"] for j in queue if j["id"] not in placed]
370
+ return {"now": now, "items": items, "unplaced": unplaced}
371
+
372
+
373
+ def status_data(con, cfg) -> dict:
374
+ mode = db.get_mode(con)
375
+ running = db.active(con)
376
+ placed: dict = {}
377
+ queue, est = control.queue_and_starts(con, cfg, placed)
378
+ live = {g.index: g for g in control.gpu_readings(con)}
379
+ gpu_rows = []
380
+ for i in cfg.gpus:
381
+ g = live.get(i) or next((d for d in cfg.detected if d.index == i), None)
382
+ gpu_rows.append(
383
+ {
384
+ "index": i,
385
+ "name": g.name if g else None,
386
+ "mem_total_mb": g.mem_total if g else None,
387
+ "mem_used_mb": g.mem_used if g else None,
388
+ "util_pct": g.util if g else None,
389
+ "temp_c": g.temp if g else None,
390
+ "power_w": g.power if g else None,
391
+ "power_limit_w": g.power_limit if g else None,
392
+ "fan_pct": g.fan if g else None,
393
+ "clock_mhz": g.clock if g else None,
394
+ "shared": i == cfg.shared_gpu,
395
+ "jobs": [j["id"] for j in running if i in (j["gpus"] or [])],
396
+ }
397
+ )
398
+ hb = db.get_kv(con, "daemon_heartbeat")
399
+ q = []
400
+ for n, j in enumerate(queue, 1):
401
+ d = job_public(con, j)
402
+ d["position"] = n
403
+ d["turn"] = j.get("turn", n) # place in queue order (est_start order can differ)
404
+ d["waiting_for"] = j.get("waiting_for", [])
405
+ d["est_start"] = iso(est.get(j["id"]))
406
+ q.append(d)
407
+ return {
408
+ "schema": 1,
409
+ "mode": mode,
410
+ "mode_since": iso(db.get_kv(con, "mode_since")) if mode != "normal" else None,
411
+ "auto_free": db.get_kv(con, "auto_free") if mode != "normal" else None,
412
+ "daemon": {
413
+ "running": daemon.is_running(),
414
+ "pid": db.get_kv(con, "daemon_pid"),
415
+ "heartbeat_age_s": None if hb is None else round(time.time() - hb, 1),
416
+ },
417
+ "slots": {"total": cfg.slots, "used": sum(j["slots"] for j in running)},
418
+ "gpus": gpu_rows,
419
+ "running": [job_public(con, j) for j in running],
420
+ "queue": q,
421
+ "recent": [job_public(con, j) for j in db.recent(con, 5)],
422
+ "cpu": control.cpu_reading(con),
423
+ "timeline": timeline(con, running, queue, placed),
424
+ "_est": est,
425
+ }
426
+
427
+
428
+ def cmd_status(a) -> int:
429
+ cfg = load_cfg()
430
+ con = db.connect()
431
+ s = status_data(con, cfg)
432
+ est = s.pop("_est")
433
+ if a.json:
434
+ _print_json(s)
435
+ return 0
436
+ mode = s["mode"]
437
+ mode_txt = {
438
+ "normal": "normal",
439
+ "free": "FREE (queue paused, machine freed)",
440
+ "soft": "FREE --soft (queue paused, jobs frozen)",
441
+ }[mode]
442
+ if mode != "normal" and s.get("auto_free"):
443
+ apps = ", ".join(s["auto_free"].get("apps") or [])
444
+ mode_txt = f"FREE (automatic: {apps} is using the GPU; resumes after it closes)"
445
+ dm = "running" if s["daemon"]["running"] else "NOT RUNNING (`gpushift start`)"
446
+ print(f"gpushift mode: {mode_txt} | daemon: {dm} | slots: {s['slots']['used']}/{s['slots']['total']} used")
447
+ print()
448
+ by_id = {j["id"]: j for j in s["running"]}
449
+
450
+ def job_line(j):
451
+ st = "" if j["state"] == "running" else f" [{j['state'].upper()}]"
452
+ exp = j["expected_duration_s"]
453
+ if j["state"] == "running" and exp and j["runtime_s"] > exp:
454
+ st = f" [over its {fmt_dur(exp)} estimate]"
455
+ return f"job {j['id']:<4} {j['preset']:<9} {trunc(j['tag'] or '', 14):<14} {fmt_dur(j['runtime_s']):>6}{st} {trunc(j['command'], 50)}"
456
+
457
+ for g in s["gpus"]:
458
+ mem = f"{g['mem_used_mb'] / 1024:.1f}/{g['mem_total_mb'] / 1024:.1f} GB" if g["mem_total_mb"] else ""
459
+ name = trunc((g["name"] or "").replace("NVIDIA ", "").replace("GeForce ", ""), 16)
460
+ label = f"GPU {g['index']} {name:<16} {mem:<13}" + ("(shared) " if g["shared"] else " ")
461
+ if not g["jobs"]:
462
+ print(f"{label}idle")
463
+ for k, jid in enumerate(g["jobs"]):
464
+ print(f"{label if k == 0 else ' ' * len(label)}{job_line(by_id[jid])}")
465
+ cpu_jobs = [j for j in s["running"] if not j["gpus"]]
466
+ label = f"{'CPU':<47}"
467
+ if cpu_jobs or not s["gpus"]:
468
+ if not cpu_jobs:
469
+ print(f"{label}idle")
470
+ for k, j in enumerate(cpu_jobs):
471
+ print(f"{label if k == 0 else ' ' * len(label)}{job_line(j)}")
472
+ print()
473
+ if s["queue"]:
474
+ print(f"Queue ({len(s['queue'])} waiting)")
475
+ print(f" {'pos':>3} {'id':<4} {'preset':<9} {'tag':<14} {'waiting':>7} {'est. start':<20} command")
476
+ raw = {q["id"]: q for q in db.queued(con)}
477
+ for j in s["queue"]:
478
+ waited = time.time() - raw[j["id"]]["submitted_at"] if j["id"] in raw else None
479
+ flag = " (resume)" if j["resume"] else ""
480
+ start = fmt_start(est.get(j["id"]), mode)
481
+ if j["waiting_for"] and est.get(j["id"]) is None:
482
+ start = "after job " + ",".join(map(str, j["waiting_for"]))
483
+ print(
484
+ f" {j['position']:>3} {j['id']:<4} {j['preset']:<9} {trunc(j['tag'] or '', 14):<14} {fmt_dur(waited):>7} "
485
+ f"{trunc(start, 20):<20} {trunc(j['command'], 40)}{flag}"
486
+ )
487
+ else:
488
+ print("Queue empty")
489
+ if s["recent"] and not a.short:
490
+ print()
491
+ print("Recent")
492
+ raw = {r["id"]: r for r in db.recent(con, 5)}
493
+ for j in s["recent"]:
494
+ ended = raw[j["id"]]["ended_at"] if j["id"] in raw else None
495
+ rc = "" if j["exit_code"] is None else f" (exit {j['exit_code']})"
496
+ print(
497
+ f" job {j['id']:<4} {j['state']:<9}{rc:<9} {trunc(j['tag'] or '', 14):<14} ran {fmt_dur(j['runtime_s']):>6}, "
498
+ f"ended {fmt_dur(time.time() - ended) if ended else '?'} ago {trunc(j['command'], 36)}"
499
+ )
500
+ return 0
501
+
502
+
503
+ def cmd_show(a) -> int:
504
+ con = db.connect()
505
+ j = job_public(con, get_job_or_fail(con, a.id))
506
+ if a.json:
507
+ _print_json(j)
508
+ else:
509
+ for k, v in j.items():
510
+ print(f"{k:>20}: {v}")
511
+ return 0
512
+
513
+
514
+ def cmd_priority(a) -> int:
515
+ con = db.connect()
516
+ get_job_or_fail(con, a.id)
517
+ msg = control.to_front(con, a.id)
518
+ if "moved" not in msg:
519
+ raise CLIError(msg)
520
+ print(msg)
521
+ return 0
522
+
523
+
524
+ def cmd_swap(a) -> int:
525
+ con = db.connect()
526
+ with db.tx(con):
527
+ x, y = get_job_or_fail(con, a.a), get_job_or_fail(con, a.b)
528
+ for j in (x, y):
529
+ if j["state"] != "queued":
530
+ raise CLIError(f"job {j['id']} is {j['state']}, not queued")
531
+ queue = db.queued(con)
532
+ order = [q["id"] for q in queue]
533
+ i, k = order.index(x["id"]), order.index(y["id"])
534
+ order[i], order[k] = order[k], order[i]
535
+ problem = control.order_problem(queue, order)
536
+ if problem:
537
+ raise CLIError(problem)
538
+ db.update(con, x["id"], position=y["position"], prio=y["prio"])
539
+ db.update(con, y["id"], position=x["position"], prio=x["prio"])
540
+ print(f"swapped jobs {a.a} and {a.b}")
541
+ return 0
542
+
543
+
544
+ def cmd_cancel(a) -> int:
545
+ con = db.connect()
546
+ rc = 0
547
+ for jid in a.ids:
548
+ msg = control.cancel(con, jid)
549
+ print(msg)
550
+ if "not found" in msg:
551
+ rc = 1
552
+ return rc
553
+
554
+
555
+ def cmd_retry(a) -> int:
556
+ con = db.connect()
557
+ with db.tx(con):
558
+ j = get_job_or_fail(con, a.id)
559
+ keep = (
560
+ "preset",
561
+ "n_gpus",
562
+ "slots",
563
+ "exclusive",
564
+ "shared",
565
+ "argv",
566
+ "command",
567
+ "shell",
568
+ "cwd",
569
+ "env",
570
+ "tag",
571
+ "project",
572
+ "session",
573
+ "eta_seconds",
574
+ "resumable",
575
+ "restartable",
576
+ "vram_mb",
577
+ )
578
+ jid = db.insert_job(
579
+ con,
580
+ state="queued",
581
+ position=db.front_position(con) if a.priority else db.back_position(con),
582
+ prio=int(a.priority),
583
+ resume=int(a.resume),
584
+ **{k: j[k] for k in keep},
585
+ )
586
+ paths.job_dir(jid).mkdir(parents=True, exist_ok=True)
587
+ ensure_daemon()
588
+ print(f"job {jid} queued (retry of job {a.id})")
589
+ return 0
590
+
591
+
592
+ def _final(con, jid) -> dict | None:
593
+ j = db.get_job(con, jid)
594
+ return j if j and j["state"] in db.FINAL else None
595
+
596
+
597
+ def _emit(data: bytes) -> None:
598
+ """Write job output. Logs written on Windows contain CRLF; normalize so the
599
+ text-mode stdout does not turn them into CR CR LF."""
600
+ if data:
601
+ sys.stdout.write(data.replace(b"\r\n", b"\n").decode(errors="replace"))
602
+ sys.stdout.flush()
603
+
604
+
605
+ def cmd_logs(a) -> int:
606
+ con = db.connect()
607
+ get_job_or_fail(con, a.id)
608
+ f = paths.log_file(a.id)
609
+ while not f.exists():
610
+ if not a.follow or _final(con, a.id):
611
+ print(f"(no output yet for job {a.id})")
612
+ return 0
613
+ time.sleep(0.5)
614
+ with open(f, "rb") as fh:
615
+ if a.tail:
616
+ _emit(b"".join(fh.read().splitlines(keepends=True)[-a.tail :]))
617
+ else:
618
+ _emit(fh.read())
619
+ if not a.follow:
620
+ return 0
621
+ try:
622
+ while True:
623
+ chunk = fh.read()
624
+ if chunk:
625
+ _emit(chunk)
626
+ elif _final(con, a.id):
627
+ _emit(fh.read())
628
+ return 0
629
+ else:
630
+ time.sleep(0.5)
631
+ except KeyboardInterrupt:
632
+ return 0
633
+
634
+
635
+ def cmd_wait(a) -> int:
636
+ con = db.connect()
637
+ get_job_or_fail(con, a.id)
638
+ deadline = None if a.timeout is None else time.time() + parse_duration(a.timeout)
639
+ while True:
640
+ j = _final(con, a.id)
641
+ if j:
642
+ ev = j["evictions"] or 0
643
+ extra = (
644
+ f"; it was evicted {ev}x when the user freed the GPUs and resumed from its own checkpoint" if ev else ""
645
+ )
646
+ print(f"job {a.id} {j['state']} (exit code {j['exit_code']}{extra})")
647
+ if j["state"] == "failed" and j["note"]:
648
+ print(j["note"])
649
+ with db.tx(con): # the caller now knows; no need to notify the session again
650
+ db.update(con, a.id, notified=1)
651
+ return 0 if j["state"] == "done" else (j["exit_code"] or 1)
652
+ if deadline and time.time() > deadline:
653
+ print(f"job {a.id} still {db.get_job(con, a.id)['state']} (timed out waiting)")
654
+ return 124
655
+ time.sleep(1)
656
+
657
+
658
+ def _vram_summary(cfg) -> str:
659
+ try:
660
+ live = [g for g in gpus.query() if g.index in cfg.gpus]
661
+ except gpus.GPUQueryError:
662
+ return "(VRAM unknown)"
663
+ return ", ".join(f"GPU {g.index} {g.mem_used / 1024:.1f}/{g.mem_total / 1024:.1f} GB" for g in live) or "(no GPUs)"
664
+
665
+
666
+ def cmd_free(a) -> int:
667
+ cfg = load_cfg()
668
+ con = db.connect()
669
+ soft = a.soft or (cfg.free_mode_default == "soft" and not a.full)
670
+ rep = control.free(con, soft)
671
+ kind = "soft: everything frozen, VRAM stays allocated" if soft else "full"
672
+ if rep["previous_mode"] != "normal":
673
+ print(f"already in free mode ({rep['previous_mode']}); now {rep['mode']}")
674
+ print(f"Free mode ON ({kind}). The queue is paused; nothing new will start.")
675
+ by = {j["id"]: j for j in db.active(con)}
676
+
677
+ def tag(jid):
678
+ j = by.get(jid) or db.get_job(con, jid)
679
+ return f"job {jid} ({j['tag']})"
680
+
681
+ for jid in rep["evicting"]:
682
+ print(f" evicting: {tag(jid)} - asked to checkpoint and exit")
683
+ for jid in rep["restarting"]:
684
+ print(f" restarting: {tag(jid)} - stopped; it runs again from the front of the queue")
685
+ for jid in rep["frozen"]:
686
+ why = "" if soft else " (it can't checkpoint and isn't --restartable)"
687
+ print(f" frozen: {tag(jid)} - paused, keeps its GPU memory{why}")
688
+ for jid in rep["pending"]:
689
+ print(f" frozen: {tag(jid)} - was just starting; it freezes itself")
690
+ if not (rep["evicting"] or rep["restarting"] or rep["frozen"] or rep["pending"]):
691
+ print(" no jobs were running")
692
+
693
+ leaving = rep["evicting"] + rep["restarting"]
694
+ if leaving and not a.no_wait:
695
+ print(
696
+ f"Waiting for jobs to leave the GPUs (up to {cfg.evict_timeout}s; Ctrl-C stops waiting, they still leave)..."
697
+ )
698
+ waiting = set(leaving)
699
+ try:
700
+ while waiting:
701
+ time.sleep(1)
702
+ control.enforce_evict_timeout(con, cfg.evict_timeout)
703
+ for jid in list(waiting):
704
+ j = db.get_job(con, jid)
705
+ if j["state"] == "queued" and jid in rep["restarting"]:
706
+ print(f" job {jid} stopped -> back at the front of the queue; it runs again on resume")
707
+ waiting.discard(jid)
708
+ elif j["state"] == "queued":
709
+ print(f" job {jid} checkpointed and exited -> back at the front of the queue")
710
+ waiting.discard(jid)
711
+ elif j["state"] == "frozen":
712
+ print(f" job {jid} did not exit within {cfg.evict_timeout}s -> frozen instead (VRAM held)")
713
+ waiting.discard(jid)
714
+ elif j["state"] in db.FINAL:
715
+ print(f" job {jid} ended ({j['state']})")
716
+ waiting.discard(jid)
717
+ except KeyboardInterrupt:
718
+ print(" (stopped waiting)")
719
+ print(f"VRAM in use now: {_vram_summary(cfg)}")
720
+ _explain_frozen(con, cfg)
721
+ print("Run `gpushift resume` to continue.")
722
+ return 0
723
+
724
+
725
+ def _explain_frozen(con, cfg) -> None:
726
+ """Say which GPUs frozen jobs keep memory on, and how to release it."""
727
+ frozen = [j for j in db.active(con) if j["state"] == "frozen" and j["gpus"]]
728
+ if not frozen:
729
+ return
730
+ try:
731
+ live = {g.index: g for g in gpus.query()}
732
+ except gpus.GPUQueryError:
733
+ live = {}
734
+ print("Frozen jobs keep their GPU memory:")
735
+ for g in sorted({g for j in frozen for g in j["gpus"]}):
736
+ mem = f" ({live[g].mem_used / 1024:.1f} GB in use)" if g in live else ""
737
+ names = ", ".join(f"job {j['id']} ({j['tag']})" for j in frozen if g in j["gpus"])
738
+ print(f" GPU {g}{mem}: {names}")
739
+ ids = " ".join(str(j["id"]) for j in frozen)
740
+ print(f"To release it: gpushift cancel {ids} (run it again later with: gpushift retry <id> --priority)")
741
+
742
+
743
+ def cmd_resume(a) -> int:
744
+ con = db.connect()
745
+ rep = control.resume(con)
746
+ if rep["previous_mode"] == "normal" and not rep["resumed"]:
747
+ print("not in free mode; nothing to resume")
748
+ return 0
749
+ print("Free mode OFF. The queue is running again.")
750
+ for jid in rep["resumed"]:
751
+ print(f" resumed: job {jid}")
752
+ for jid in rep["evicting"]:
753
+ print(f" job {jid} is still checkpointing; it will restart from the front of the queue")
754
+ evicted = [j for j in db.queued(con) if j["resume"]]
755
+ for j in evicted:
756
+ print(f" queued: job {j['id']} will restart from its checkpoint")
757
+ ensure_daemon()
758
+ return 0
759
+
760
+
761
+ def cmd_start(a) -> int:
762
+ if daemon.is_running():
763
+ print("daemon already running")
764
+ return 0
765
+ if ensure_daemon(quiet=True):
766
+ print("daemon started")
767
+ return 0
768
+ raise CLIError(f"daemon did not start; see {paths.daemon_log()}")
769
+
770
+
771
+ def cmd_stop(a) -> int:
772
+ if daemon.stop():
773
+ print("daemon stopped (running jobs keep running; queued jobs wait until it is started again)")
774
+ else:
775
+ print("daemon was not running")
776
+ return 0
777
+
778
+
779
+ def cmd_daemon(a) -> int:
780
+ return daemon.main()
781
+
782
+
783
+ def cmd_config(a) -> int:
784
+ f = paths.config_file()
785
+ if a.path:
786
+ print(f)
787
+ return 0
788
+ if a.edit:
789
+ if not f.exists():
790
+ f.parent.mkdir(parents=True, exist_ok=True)
791
+ f.write_text(config.template(gpus.detect()))
792
+ editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ("notepad" if os.name == "nt" else "vi")
793
+ return subprocess.call([*shlex.split(editor, posix=os.name != "nt"), str(f)])
794
+ cfg = load_cfg()
795
+ print(f"# {f}{'' if f.exists() else ' (not created yet; defaults shown)'}")
796
+ if a.json:
797
+ _print_json(cfg.to_dict())
798
+ else:
799
+ for k, v in cfg.to_dict().items():
800
+ print(f"{k} = {v}")
801
+ return 0
802
+
803
+
804
+ def cmd_init(a) -> int:
805
+ print(f"gpushift {__version__} on {sys.platform}, python {sys.executable}")
806
+ try:
807
+ detected = gpus.query()
808
+ except gpus.GPUQueryError as e:
809
+ print(f"warning: {e}; continuing in CPU-only mode")
810
+ detected = []
811
+ if detected:
812
+ for g in detected:
813
+ print(f" GPU {g.index}: {g.name}, {g.mem_total / 1024:.1f} GB ({g.mem_used / 1024:.1f} GB in use)")
814
+ else:
815
+ print(" no NVIDIA GPUs found -> CPU-only mode (only --cpu/--exclusive jobs)")
816
+ f = paths.config_file()
817
+ if f.exists() and not a.force:
818
+ print(f"config: keeping existing {f}")
819
+ else:
820
+ f.parent.mkdir(parents=True, exist_ok=True)
821
+ f.write_text(config.template(detected))
822
+ print(f"config: wrote {f}")
823
+ cfg = load_cfg()
824
+ for g in cfg.detected:
825
+ if g.free_perc < cfg.gpu_free_perc:
826
+ print(
827
+ f" note: GPU {g.index} is only {g.free_perc:.0f}% free right now (display/other apps); jobs wait until "
828
+ f">= {cfg.gpu_free_perc}% is free. Lower gpu_free_perc in the config if this is normal for it."
829
+ )
830
+ db.connect()
831
+ print(f"state: {paths.state_dir()}")
832
+ if not a.no_autostart:
833
+ try:
834
+ print(f"autostart: {autostart.install()}")
835
+ except Exception as e:
836
+ print(f"autostart: failed ({e}); start the daemon manually with `gpushift start`")
837
+ print("daemon: running" if ensure_daemon(quiet=True) else f"daemon: FAILED to start (see {paths.daemon_log()})")
838
+ print(f"\nReady: {len(cfg.gpus)} GPU(s), {cfg.slots} slots, shared GPU for --light: {cfg.shared_gpu}.")
839
+ print("Try: gpushift submit --cpu -- python -c \"print('hello from gpushift')\" then gpushift status")
840
+ return 0
841
+
842
+
843
+ def cmd_doctor(a) -> int:
844
+ problems = 0
845
+
846
+ def check(ok: bool | None, label: str, detail: str = "") -> None:
847
+ nonlocal problems
848
+ tag = {True: "ok ", False: "FAIL", None: "warn"}[ok]
849
+ if ok is False:
850
+ problems += 1
851
+ print(f"[{tag}] {label}{(': ' + detail) if detail else ''}")
852
+
853
+ check(True, "gpushift", f"{__version__}, python {sys.version.split()[0]} at {sys.executable}")
854
+ smi = gpus.nvidia_smi_path()
855
+ try:
856
+ found = gpus.query()
857
+ check(
858
+ True if found else None,
859
+ "GPUs",
860
+ ", ".join(f"{g.index}:{g.name}" for g in found) or "none found (CPU-only mode)",
861
+ )
862
+ except gpus.GPUQueryError as e:
863
+ check(False, "GPUs", str(e))
864
+ if not smi and not os.environ.get("GPUSHIFT_FAKE_GPUS"):
865
+ check(None, "nvidia-smi", "not found; install the NVIDIA driver to use GPUs")
866
+ f = paths.config_file()
867
+ try:
868
+ cfg = config.load()
869
+ check(
870
+ True,
871
+ "config",
872
+ f"{f if f.exists() else 'defaults (no config file; run gpushift init)'}; gpus={cfg.gpus} slots={cfg.slots} shared_gpu={cfg.shared_gpu}",
873
+ )
874
+ for g in cfg.detected:
875
+ if g.free_perc < cfg.gpu_free_perc:
876
+ check(
877
+ None,
878
+ f"GPU {g.index} memory",
879
+ f"only {g.free_perc:.0f}% free (< gpu_free_perc={cfg.gpu_free_perc}); new jobs will not start on it",
880
+ )
881
+ except config.ConfigError as e:
882
+ check(False, "config", str(e))
883
+ try:
884
+ con = db.connect()
885
+ n = con.execute("SELECT COUNT(*) FROM jobs").fetchone()[0]
886
+ check(True, "database", f"{paths.db_file()} ({n} jobs)")
887
+ hb = db.get_kv(con, "daemon_heartbeat")
888
+ running = daemon.is_running()
889
+ if running:
890
+ age = time.time() - hb if hb else None
891
+ check(
892
+ age is not None and age < 15,
893
+ "daemon",
894
+ f"running, last heartbeat {fmt_dur(age)} ago" if age is not None else "running, no heartbeat yet",
895
+ )
896
+ else:
897
+ check(False, "daemon", "not running; start it with `gpushift start`")
898
+ mode = db.get_mode(con)
899
+ check(
900
+ True if mode == "normal" else None,
901
+ "mode",
902
+ mode + ("" if mode == "normal" else " (queue paused; `gpushift resume`)"),
903
+ )
904
+ except Exception as e:
905
+ check(False, "database", str(e))
906
+ a_st = autostart.status()
907
+ check(True if a_st else None, "autostart", a_st or "not registered (`gpushift init` registers it)")
908
+ print("\nall good" if problems == 0 else f"\n{problems} problem(s)")
909
+ return 0 if problems == 0 else 1
910
+
911
+
912
+ def cmd_hook(a) -> int:
913
+ from . import hook
914
+
915
+ return hook.main(a.event)
916
+
917
+
918
+ def cmd_autostart(a) -> int:
919
+ print(autostart.install() if a.action == "on" else autostart.uninstall())
920
+ return 0
921
+
922
+
923
+ def cmd_ui(a) -> int:
924
+ cfg = load_cfg()
925
+ if not cfg.ui_port:
926
+ raise CLIError("the dashboard is turned off (ui_port = 0 in the config)")
927
+ ensure_daemon()
928
+ url = f"http://127.0.0.1:{cfg.ui_port}/"
929
+ print(f"gpushift dashboard: {url}")
930
+ if not a.no_open:
931
+ import webbrowser
932
+
933
+ webbrowser.open(url)
934
+ return 0
935
+
936
+
937
+ # ---------------------------------------------------------------- parser
938
+
939
+
940
+ def build_parser() -> argparse.ArgumentParser:
941
+ p = argparse.ArgumentParser(
942
+ prog="gpushift", description="Shared GPU job queue. Submit jobs instead of launching them directly."
943
+ )
944
+ p.add_argument("--version", action="version", version=f"gpushift {__version__}")
945
+ sub = p.add_subparsers(dest="command", metavar="<command>")
946
+
947
+ s = sub.add_parser(
948
+ "submit",
949
+ help="queue a job: gpushift submit --one -- python train.py",
950
+ description="Queue a job. Presets: --light (share a GPU), --one (1 GPU), --two (all GPUs), "
951
+ "--exclusive (whole machine), --cpu (no GPU).",
952
+ )
953
+ g = s.add_mutually_exclusive_group()
954
+ for name in PRESETS:
955
+ g.add_argument(f"--{name}", dest="preset", action="store_const", const=name)
956
+ s.add_argument("--gpus", type=int, help="override the number of GPUs")
957
+ s.add_argument("--slots", type=int, help="override the number of slots")
958
+ s.add_argument("--tag", help="label (default: project/repo name)")
959
+ s.add_argument("--eta", help="expected runtime, e.g. 40m, 2h, 1h30m")
960
+ s.add_argument(
961
+ "--resumable",
962
+ action="store_true",
963
+ help="job checkpoints on SIGTERM/stop file (auto-detected with gpushift.checkpoint)",
964
+ )
965
+ s.add_argument(
966
+ "--vram",
967
+ help="GPU memory the job needs per GPU, e.g. 6G or 500M. Jobs with a known need may share a GPU "
968
+ "(gpushift also learns it from earlier runs of the same command)",
969
+ )
970
+ s.add_argument(
971
+ "--restartable",
972
+ action="store_true",
973
+ help="safe to stop and run again (e.g. it skips work it already finished): `gpushift free` stops it "
974
+ "and requeues it at the front instead of freezing it",
975
+ )
976
+ s.add_argument(
977
+ "--priority", action="store_true", help="put the job at the front of the queue (only when the user asks)"
978
+ )
979
+ s.add_argument(
980
+ "--after",
981
+ action="append",
982
+ metavar="JOB",
983
+ help="start only after these jobs finished successfully, e.g. --after 12 or --after 12,13 "
984
+ "(if one fails or is cancelled, this job is cancelled)",
985
+ )
986
+ s.add_argument("--no-start", action="store_true", help="do not auto-start the daemon")
987
+ s.add_argument("--json", action="store_true")
988
+ s.add_argument("cmd", nargs=argparse.REMAINDER, help="command to run (after --)")
989
+ s.set_defaults(func=cmd_submit)
990
+
991
+ s = sub.add_parser("status", aliases=["st", "queue", "q"], help="show GPUs, running jobs and the queue")
992
+ s.add_argument("--json", action="store_true", help="stable machine-readable output (schema 1)")
993
+ s.add_argument("--short", action="store_true", help="omit recent jobs")
994
+ s.set_defaults(func=cmd_status)
995
+
996
+ s = sub.add_parser("show", help="show one job's details")
997
+ s.add_argument("id", type=int)
998
+ s.add_argument("--json", action="store_true")
999
+ s.set_defaults(func=cmd_show)
1000
+
1001
+ s = sub.add_parser("priority", help="move a queued job to the front")
1002
+ s.add_argument("id", type=int)
1003
+ s.set_defaults(func=cmd_priority)
1004
+
1005
+ s = sub.add_parser("swap", help="swap two queued jobs")
1006
+ s.add_argument("a", type=int)
1007
+ s.add_argument("b", type=int)
1008
+ s.set_defaults(func=cmd_swap)
1009
+
1010
+ s = sub.add_parser("cancel", help="cancel queued or running jobs")
1011
+ s.add_argument("ids", type=int, nargs="+")
1012
+ s.set_defaults(func=cmd_cancel)
1013
+
1014
+ s = sub.add_parser("retry", help="queue a copy of a finished/failed job")
1015
+ s.add_argument("id", type=int)
1016
+ s.add_argument("--priority", action="store_true")
1017
+ s.add_argument("--resume", action="store_true", help="set GPUSHIFT_RESUME=1 (continue from checkpoint)")
1018
+ s.set_defaults(func=cmd_retry)
1019
+
1020
+ s = sub.add_parser("logs", help="print a job's output")
1021
+ s.add_argument("id", type=int)
1022
+ s.add_argument("-f", "--follow", action="store_true", help="keep printing until the job ends")
1023
+ s.add_argument("-n", "--tail", type=int, help="only the last N lines")
1024
+ s.set_defaults(func=cmd_logs)
1025
+
1026
+ s = sub.add_parser("wait", help="block until a job ends; exit code mirrors the job")
1027
+ s.add_argument("id", type=int)
1028
+ s.add_argument("--timeout", help="give up after this long (e.g. 30m)")
1029
+ s.set_defaults(func=cmd_wait)
1030
+
1031
+ s = sub.add_parser("free", help="pause the queue and free the GPUs (user only)")
1032
+ m = s.add_mutually_exclusive_group()
1033
+ m.add_argument("--soft", action="store_true", help="freeze everything (fast; VRAM stays allocated)")
1034
+ m.add_argument("--full", action="store_true", help="evict resumable jobs, freeze the rest (default)")
1035
+ s.add_argument("--no-wait", action="store_true", help="do not wait for evicted jobs to exit")
1036
+ s.set_defaults(func=cmd_free)
1037
+
1038
+ s = sub.add_parser("resume", help="leave free mode: unfreeze jobs and restart the queue (user only)")
1039
+ s.set_defaults(func=cmd_resume)
1040
+
1041
+ s = sub.add_parser("init", help="detect GPUs, write config, start the daemon, register autostart")
1042
+ s.add_argument("--force", action="store_true", help="overwrite an existing config")
1043
+ s.add_argument("--no-autostart", action="store_true")
1044
+ s.set_defaults(func=cmd_init)
1045
+
1046
+ s = sub.add_parser("doctor", help="check the installation")
1047
+ s.set_defaults(func=cmd_doctor)
1048
+
1049
+ s = sub.add_parser("config", help="show or edit the config")
1050
+ s.add_argument("--edit", action="store_true")
1051
+ s.add_argument("--path", action="store_true")
1052
+ s.add_argument("--json", action="store_true")
1053
+ s.set_defaults(func=cmd_config)
1054
+
1055
+ s = sub.add_parser("start", help="start the daemon")
1056
+ s.set_defaults(func=cmd_start)
1057
+ s = sub.add_parser("stop", help="stop the daemon (jobs keep running)")
1058
+ s.set_defaults(func=cmd_stop)
1059
+ s = sub.add_parser("autostart", help="register/unregister the daemon at login")
1060
+ s.add_argument("action", choices=["on", "off"])
1061
+ s.set_defaults(func=cmd_autostart)
1062
+ s = sub.add_parser("ui", aliases=["dashboard"], help="open the web dashboard in your browser")
1063
+ s.add_argument("--no-open", action="store_true", help="only print the address")
1064
+ s.set_defaults(func=cmd_ui)
1065
+
1066
+ s = sub.add_parser("daemon", help=argparse.SUPPRESS)
1067
+ s.set_defaults(func=cmd_daemon)
1068
+ s = sub.add_parser("hook", help=argparse.SUPPRESS)
1069
+ s.add_argument("event", nargs="?", default="pretooluse")
1070
+ s.set_defaults(func=cmd_hook)
1071
+ return p
1072
+
1073
+
1074
+ def main(argv: list[str] | None = None) -> None:
1075
+ for stream in (sys.stdout, sys.stderr):
1076
+ try:
1077
+ stream.reconfigure(errors="replace")
1078
+ except (AttributeError, ValueError):
1079
+ pass
1080
+ argv = list(sys.argv[1:] if argv is None else argv)
1081
+ # `gpushift submit [opts] -- cmd...`: split before argparse sees the command.
1082
+ cmd = None
1083
+ if argv and argv[0] == "submit" and "--" in argv:
1084
+ i = argv.index("--")
1085
+ argv, cmd = argv[:i], argv[i + 1 :]
1086
+ p = build_parser()
1087
+ a = p.parse_args(argv)
1088
+ if cmd is not None:
1089
+ a.cmd = cmd
1090
+ if not getattr(a, "func", None):
1091
+ p.print_help()
1092
+ sys.exit(0)
1093
+ try:
1094
+ rc = a.func(a)
1095
+ except CLIError as e:
1096
+ print(f"gpushift: error: {e}", file=sys.stderr)
1097
+ rc = 2
1098
+ except KeyboardInterrupt:
1099
+ rc = 130
1100
+ sys.exit(rc)