gpumesh 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.
- gpumesh/__init__.py +3 -0
- gpumesh/__main__.py +5 -0
- gpumesh/capability.py +148 -0
- gpumesh/cli.py +225 -0
- gpumesh/client.py +62 -0
- gpumesh/db.py +295 -0
- gpumesh/sandbox.py +142 -0
- gpumesh/security.py +183 -0
- gpumesh/server.py +172 -0
- gpumesh/tunnel.py +18 -0
- gpumesh/worker.py +104 -0
- gpumesh-0.1.0.dist-info/METADATA +152 -0
- gpumesh-0.1.0.dist-info/RECORD +16 -0
- gpumesh-0.1.0.dist-info/WHEEL +5 -0
- gpumesh-0.1.0.dist-info/entry_points.txt +2 -0
- gpumesh-0.1.0.dist-info/top_level.txt +1 -0
gpumesh/__init__.py
ADDED
gpumesh/__main__.py
ADDED
gpumesh/capability.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Hardware probing and benchmarking.
|
|
2
|
+
|
|
3
|
+
Detects the best compute device available and produces a single numeric
|
|
4
|
+
capability score used by the scheduler. torch and psutil are optional:
|
|
5
|
+
without them the probe falls back to CPU info and a pure-Python benchmark.
|
|
6
|
+
|
|
7
|
+
GPU detection uses nvidia-smi (no PyTorch required for detection).
|
|
8
|
+
PyTorch with CUDA is auto-installed when a GPU is detected.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import shutil
|
|
14
|
+
import socket
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _detect_nvidia_gpu() -> dict:
|
|
21
|
+
"""Detect NVIDIA GPU using nvidia-smi (no PyTorch required)."""
|
|
22
|
+
if shutil.which("nvidia-smi") is None:
|
|
23
|
+
return None
|
|
24
|
+
try:
|
|
25
|
+
result = subprocess.run(
|
|
26
|
+
["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"],
|
|
27
|
+
capture_output=True,
|
|
28
|
+
text=True,
|
|
29
|
+
timeout=5,
|
|
30
|
+
)
|
|
31
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
32
|
+
parts = result.stdout.strip().split(",")
|
|
33
|
+
name = parts[0].strip()
|
|
34
|
+
memory = parts[1].strip() if len(parts) > 1 else "unknown"
|
|
35
|
+
return {"name": name, "memory_mb": memory}
|
|
36
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
37
|
+
pass
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _install_pytorch_cuda():
|
|
42
|
+
"""Install PyTorch with CUDA support if NVIDIA GPU detected."""
|
|
43
|
+
print("[mesh] NVIDIA GPU detected, installing PyTorch with CUDA support...")
|
|
44
|
+
try:
|
|
45
|
+
subprocess.run(
|
|
46
|
+
[sys.executable, "-m", "pip", "install", "torch", "torchvision", "torchaudio",
|
|
47
|
+
"--index-url", "https://download.pytorch.org/whl/cu118"],
|
|
48
|
+
check=True,
|
|
49
|
+
timeout=300,
|
|
50
|
+
)
|
|
51
|
+
print("[mesh] PyTorch with CUDA installed successfully!")
|
|
52
|
+
return True
|
|
53
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
|
54
|
+
print(f"[mesh] Failed to install PyTorch: {e}")
|
|
55
|
+
print("[mesh] Continuing with CPU-only mode.")
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def probe_device() -> dict:
|
|
60
|
+
info = {
|
|
61
|
+
"hostname": socket.gethostname(),
|
|
62
|
+
"platform": platform.platform(),
|
|
63
|
+
"cpu_count": os.cpu_count() or 1,
|
|
64
|
+
"device": "cpu",
|
|
65
|
+
"device_name": platform.processor() or "cpu",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# First check for NVIDIA GPU using nvidia-smi (fast, no PyTorch needed)
|
|
69
|
+
nvidia_gpu = _detect_nvidia_gpu()
|
|
70
|
+
if nvidia_gpu:
|
|
71
|
+
info["nvidia_gpu"] = nvidia_gpu["name"]
|
|
72
|
+
info["nvidia_memory"] = nvidia_gpu["memory_mb"]
|
|
73
|
+
|
|
74
|
+
# Try to use PyTorch for GPU detection and benchmarking
|
|
75
|
+
try:
|
|
76
|
+
import torch
|
|
77
|
+
if torch.cuda.is_available():
|
|
78
|
+
info["device"] = "cuda"
|
|
79
|
+
info["device_name"] = torch.cuda.get_device_name(0)
|
|
80
|
+
elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
|
|
81
|
+
info["device"] = "mps"
|
|
82
|
+
info["device_name"] = "Apple Silicon GPU"
|
|
83
|
+
except ImportError:
|
|
84
|
+
# PyTorch not installed - try to auto-install if NVIDIA GPU detected
|
|
85
|
+
if nvidia_gpu:
|
|
86
|
+
if _install_pytorch_cuda():
|
|
87
|
+
try:
|
|
88
|
+
import torch
|
|
89
|
+
if torch.cuda.is_available():
|
|
90
|
+
info["device"] = "cuda"
|
|
91
|
+
info["device_name"] = torch.cuda.get_device_name(0)
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
import psutil
|
|
97
|
+
info["ram_gb"] = round(psutil.virtual_memory().total / 1e9, 1)
|
|
98
|
+
except ImportError:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
return info
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _bench_torch(device: str) -> float:
|
|
105
|
+
"""Time a matmul on the given torch device; return ops/sec score."""
|
|
106
|
+
import torch
|
|
107
|
+
|
|
108
|
+
n = 1024
|
|
109
|
+
a = torch.rand(n, n, device=device)
|
|
110
|
+
b = torch.rand(n, n, device=device)
|
|
111
|
+
# warmup
|
|
112
|
+
(a @ b).sum().item()
|
|
113
|
+
start = time.perf_counter()
|
|
114
|
+
iters = 10
|
|
115
|
+
for _ in range(iters):
|
|
116
|
+
c = a @ b
|
|
117
|
+
c.sum().item() # force sync on cuda/mps
|
|
118
|
+
elapsed = time.perf_counter() - start
|
|
119
|
+
flops = 2 * n**3 * iters
|
|
120
|
+
return flops / elapsed / 1e9 # GFLOP/s
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _bench_python() -> float:
|
|
124
|
+
"""Pure-Python matmul fallback. Slow by design; scores are comparable
|
|
125
|
+
across machines because everyone runs the same loop."""
|
|
126
|
+
n = 48
|
|
127
|
+
a = [[(i * j) % 7 / 7.0 for j in range(n)] for i in range(n)]
|
|
128
|
+
b = [[(i + j) % 5 / 5.0 for j in range(n)] for i in range(n)]
|
|
129
|
+
start = time.perf_counter()
|
|
130
|
+
iters = 5
|
|
131
|
+
for _ in range(iters):
|
|
132
|
+
[[sum(a[i][k] * b[k][j] for k in range(n)) for j in range(n)] for i in range(n)]
|
|
133
|
+
elapsed = time.perf_counter() - start
|
|
134
|
+
flops = 2 * n**3 * iters
|
|
135
|
+
return flops / elapsed / 1e9
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def benchmark(device: str) -> float:
|
|
139
|
+
try:
|
|
140
|
+
return round(_bench_torch(device), 3)
|
|
141
|
+
except ImportError:
|
|
142
|
+
return round(_bench_python(), 3)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def full_probe() -> dict:
|
|
146
|
+
info = probe_device()
|
|
147
|
+
info["score"] = benchmark(info["device"])
|
|
148
|
+
return info
|
gpumesh/cli.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""gpumesh command line interface.
|
|
2
|
+
|
|
3
|
+
gpumesh serve [--port 8000] [--token SECRET] [--public]
|
|
4
|
+
gpumesh join URL [--token SECRET]
|
|
5
|
+
gpumesh quickjoin URL --token TOKEN # One-click setup & join
|
|
6
|
+
gpumesh submit SCRIPT --payloads FILE [--wait] --url URL [--token SECRET]
|
|
7
|
+
gpumesh status JOB_ID --url URL [--token SECRET]
|
|
8
|
+
gpumesh cancel JOB_ID --url URL [--token SECRET]
|
|
9
|
+
gpumesh workers --url URL [--token SECRET]
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
import socket
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from . import client, server, tunnel, worker
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _lan_ip() -> str:
|
|
23
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
24
|
+
try:
|
|
25
|
+
s.connect(("8.8.8.8", 80))
|
|
26
|
+
return s.getsockname()[0]
|
|
27
|
+
except OSError:
|
|
28
|
+
return "127.0.0.1"
|
|
29
|
+
finally:
|
|
30
|
+
s.close()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def cmd_serve(args):
|
|
34
|
+
token = args.token or secrets.token_urlsafe(12)
|
|
35
|
+
httpd = server.serve("0.0.0.0", args.port, args.db, token)
|
|
36
|
+
ip = _lan_ip()
|
|
37
|
+
print(f"[mesh] coordinator listening on 0.0.0.0:{args.port}")
|
|
38
|
+
print(f"[mesh] token: {token}")
|
|
39
|
+
print(f"[mesh] LAN join command:")
|
|
40
|
+
print(f" gpumesh join http://{ip}:{args.port} --token {token}")
|
|
41
|
+
if args.public:
|
|
42
|
+
tunnel.open_tunnel(args.port)
|
|
43
|
+
print("[mesh] Ctrl+C to stop")
|
|
44
|
+
try:
|
|
45
|
+
httpd.serve_forever()
|
|
46
|
+
except KeyboardInterrupt:
|
|
47
|
+
print("\n[mesh] shutting down")
|
|
48
|
+
httpd.gpumesh_stop.set()
|
|
49
|
+
httpd.shutdown()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cmd_join(args):
|
|
53
|
+
worker.run_worker(args.url, args.token, task_timeout=args.timeout)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_submit(args):
|
|
57
|
+
job_id = client.submit_job(args.url, args.token, args.script,
|
|
58
|
+
args.payloads, name=args.name)
|
|
59
|
+
print(f"[client] submitted job {job_id}")
|
|
60
|
+
if args.wait:
|
|
61
|
+
job = client.wait_for_job(args.url, args.token, job_id)
|
|
62
|
+
client.print_job(job)
|
|
63
|
+
else:
|
|
64
|
+
print(f"[client] check progress: gpumesh status {job_id}"
|
|
65
|
+
f" --url {args.url} --token {args.token}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cmd_status(args):
|
|
69
|
+
job = client.get_status(args.url, args.token, args.job_id)
|
|
70
|
+
client.print_job(job)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cmd_cancel(args):
|
|
74
|
+
result = client.cancel_job(args.url, args.token, args.job_id)
|
|
75
|
+
if result is None:
|
|
76
|
+
print(f"[client] job {args.job_id} not found")
|
|
77
|
+
else:
|
|
78
|
+
print(f"[client] cancelled job {args.job_id}")
|
|
79
|
+
print(f" pending tasks cancelled: {result['pending']}")
|
|
80
|
+
print(f" running tasks cancelled: {result['running']}")
|
|
81
|
+
print(f" already finished: {result['already_finished']}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cmd_workers(args):
|
|
85
|
+
from .worker import MeshClient
|
|
86
|
+
|
|
87
|
+
resp = MeshClient(args.url, args.token).call("GET", "/api/workers")
|
|
88
|
+
for w in resp["workers"]:
|
|
89
|
+
state = "alive" if w["alive"] else "dead"
|
|
90
|
+
print(f" {w['id']} {w['hostname']:<20} {w['device']:<5} "
|
|
91
|
+
f"score={w['score']:<10} [{state}]")
|
|
92
|
+
if not resp["workers"]:
|
|
93
|
+
print(" (no workers yet)")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_quickjoin(args):
|
|
97
|
+
"""One-click setup: install dependencies, detect GPU, and join mesh."""
|
|
98
|
+
url = args.url
|
|
99
|
+
token = args.token
|
|
100
|
+
|
|
101
|
+
print("[mesh] Quick Join - Setting up your machine as a worker...")
|
|
102
|
+
print()
|
|
103
|
+
|
|
104
|
+
# Step 1: Check Python version
|
|
105
|
+
print("[1/4] Checking Python version...")
|
|
106
|
+
if sys.version_info < (3, 9):
|
|
107
|
+
print("[mesh] ERROR: Python 3.9 or higher is required.")
|
|
108
|
+
print("[mesh] Please install Python 3.9+ from https://www.python.org/downloads/")
|
|
109
|
+
return
|
|
110
|
+
print(f"[mesh] Python {sys.version_info.major}.{sys.version_info.minor} ✓")
|
|
111
|
+
|
|
112
|
+
# Step 2: Install gpumesh if not already installed
|
|
113
|
+
print("[2/4] Checking gpumesh installation...")
|
|
114
|
+
try:
|
|
115
|
+
import gpumesh
|
|
116
|
+
print("[mesh] gpumesh already installed ✓")
|
|
117
|
+
except ImportError:
|
|
118
|
+
print("[mesh] Installing gpumesh...")
|
|
119
|
+
try:
|
|
120
|
+
subprocess.run(
|
|
121
|
+
[sys.executable, "-m", "pip", "install", "-e", "."],
|
|
122
|
+
check=True,
|
|
123
|
+
timeout=120,
|
|
124
|
+
)
|
|
125
|
+
print("[mesh] gpumesh installed ✓")
|
|
126
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
|
127
|
+
print(f"[mesh] ERROR: Failed to install gpumesh: {e}")
|
|
128
|
+
print("[mesh] Please install manually: pip install gpumesh")
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
# Step 3: Detect hardware and install GPU support if needed
|
|
132
|
+
print("[3/4] Detecting hardware...")
|
|
133
|
+
from . import capability
|
|
134
|
+
|
|
135
|
+
# Check for NVIDIA GPU using nvidia-smi
|
|
136
|
+
nvidia_gpu = capability._detect_nvidia_gpu()
|
|
137
|
+
if nvidia_gpu:
|
|
138
|
+
print(f"[mesh] NVIDIA GPU detected: {nvidia_gpu['name']}")
|
|
139
|
+
|
|
140
|
+
# Check if PyTorch with CUDA is available
|
|
141
|
+
try:
|
|
142
|
+
import torch
|
|
143
|
+
if torch.cuda.is_available():
|
|
144
|
+
print(f"[mesh] PyTorch with CUDA ready ✓")
|
|
145
|
+
else:
|
|
146
|
+
print("[mesh] PyTorch installed but CUDA not available")
|
|
147
|
+
capability._install_pytorch_cuda()
|
|
148
|
+
except ImportError:
|
|
149
|
+
print("[mesh] PyTorch not found, installing with CUDA support...")
|
|
150
|
+
capability._install_pytorch_cuda()
|
|
151
|
+
else:
|
|
152
|
+
print("[mesh] No NVIDIA GPU detected, using CPU")
|
|
153
|
+
|
|
154
|
+
# Step 4: Join the mesh
|
|
155
|
+
print("[4/4] Joining the mesh...")
|
|
156
|
+
print(f"[mesh] Connecting to {url}...")
|
|
157
|
+
print()
|
|
158
|
+
|
|
159
|
+
# Now run the normal join command
|
|
160
|
+
worker.run_worker(url, token)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _add_conn_args(p):
|
|
164
|
+
p.add_argument("--url", default=os.environ.get("GPUMESH_URL", ""),
|
|
165
|
+
help="coordinator URL (or set GPUMESH_URL)")
|
|
166
|
+
p.add_argument("--token", default=os.environ.get("GPUMESH_TOKEN", ""),
|
|
167
|
+
help="shared auth token (or set GPUMESH_TOKEN)")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def main():
|
|
171
|
+
ap = argparse.ArgumentParser(prog="gpumesh",
|
|
172
|
+
description="borrow your friends' GPUs")
|
|
173
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
174
|
+
|
|
175
|
+
p = sub.add_parser("serve", help="start a coordinator on this machine")
|
|
176
|
+
p.add_argument("--port", type=int, default=8000)
|
|
177
|
+
p.add_argument("--db", default="gpumesh.db")
|
|
178
|
+
p.add_argument("--token", default="", help="auth token (random if omitted)")
|
|
179
|
+
p.add_argument("--public", action="store_true",
|
|
180
|
+
help="expose a public URL via ngrok")
|
|
181
|
+
p.set_defaults(func=cmd_serve)
|
|
182
|
+
|
|
183
|
+
p = sub.add_parser("join", help="offer this machine's compute to a mesh")
|
|
184
|
+
p.add_argument("url", help="coordinator URL")
|
|
185
|
+
p.add_argument("--token", default=os.environ.get("GPUMESH_TOKEN", ""))
|
|
186
|
+
p.add_argument("--timeout", type=float, default=240.0,
|
|
187
|
+
help="per-task wall clock limit in seconds")
|
|
188
|
+
p.set_defaults(func=cmd_join)
|
|
189
|
+
|
|
190
|
+
p = sub.add_parser("submit", help="submit a job to the mesh")
|
|
191
|
+
p.add_argument("script", help="python script to run for every payload")
|
|
192
|
+
p.add_argument("--payloads", required=True,
|
|
193
|
+
help="JSON file: list of payload objects (each may set 'cost')")
|
|
194
|
+
p.add_argument("--name", default="")
|
|
195
|
+
p.add_argument("--wait", action="store_true", help="block until finished")
|
|
196
|
+
_add_conn_args(p)
|
|
197
|
+
p.set_defaults(func=cmd_submit)
|
|
198
|
+
|
|
199
|
+
p = sub.add_parser("status", help="show job progress and results")
|
|
200
|
+
p.add_argument("job_id")
|
|
201
|
+
_add_conn_args(p)
|
|
202
|
+
p.set_defaults(func=cmd_status)
|
|
203
|
+
|
|
204
|
+
p = sub.add_parser("cancel", help="cancel a running job")
|
|
205
|
+
p.add_argument("job_id")
|
|
206
|
+
_add_conn_args(p)
|
|
207
|
+
p.set_defaults(func=cmd_cancel)
|
|
208
|
+
|
|
209
|
+
p = sub.add_parser("quickjoin", help="one-click setup: install, detect GPU, and join mesh")
|
|
210
|
+
p.add_argument("url", help="coordinator URL")
|
|
211
|
+
p.add_argument("--token", required=True, help="shared auth token")
|
|
212
|
+
p.set_defaults(func=cmd_quickjoin)
|
|
213
|
+
|
|
214
|
+
p = sub.add_parser("workers", help="list workers in the mesh")
|
|
215
|
+
_add_conn_args(p)
|
|
216
|
+
p.set_defaults(func=cmd_workers)
|
|
217
|
+
|
|
218
|
+
args = ap.parse_args()
|
|
219
|
+
if hasattr(args, "url") and not args.url and args.cmd != "serve":
|
|
220
|
+
ap.error("--url required (or set GPUMESH_URL)")
|
|
221
|
+
args.func(args)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if __name__ == "__main__":
|
|
225
|
+
main()
|
gpumesh/client.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Client-side helpers: submit jobs, poll status, pretty-print results."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from .worker import MeshClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def cancel_job(url: str, token: str, job_id: str) -> dict:
|
|
11
|
+
"""Cancel a running job. Returns a summary of cancelled tasks."""
|
|
12
|
+
return MeshClient(url, token).call("DELETE", f"/api/jobs/{job_id}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def submit_job(url: str, token: str, script_path: str, payloads_path: str,
|
|
16
|
+
name: str = "") -> str:
|
|
17
|
+
with open(script_path) as f:
|
|
18
|
+
script = f.read()
|
|
19
|
+
with open(payloads_path) as f:
|
|
20
|
+
payloads = json.load(f)
|
|
21
|
+
if not isinstance(payloads, list):
|
|
22
|
+
raise SystemExit("payloads file must contain a JSON list")
|
|
23
|
+
|
|
24
|
+
mesh = MeshClient(url, token)
|
|
25
|
+
resp = mesh.call("POST", "/api/jobs", {
|
|
26
|
+
"name": name or script_path,
|
|
27
|
+
"script": script,
|
|
28
|
+
"payloads": payloads,
|
|
29
|
+
})
|
|
30
|
+
return resp["job_id"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_status(url: str, token: str, job_id: str) -> dict:
|
|
34
|
+
return MeshClient(url, token).call("GET", f"/api/jobs/{job_id}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def wait_for_job(url: str, token: str, job_id: str, poll: float = 3.0) -> dict:
|
|
38
|
+
while True:
|
|
39
|
+
job = get_status(url, token, job_id)
|
|
40
|
+
counts = job["counts"]
|
|
41
|
+
total = sum(counts.values())
|
|
42
|
+
done = counts.get("done", 0) + counts.get("failed", 0)
|
|
43
|
+
sys.stdout.write(f"\r[job {job_id}] {done}/{total} tasks finished "
|
|
44
|
+
f"({counts}) ")
|
|
45
|
+
sys.stdout.flush()
|
|
46
|
+
if job["finished"]:
|
|
47
|
+
print()
|
|
48
|
+
return job
|
|
49
|
+
time.sleep(poll)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def print_job(job: dict):
|
|
53
|
+
print(f"job: {job['name']} ({job['id']}) finished={job['finished']}")
|
|
54
|
+
for t in job["tasks"]:
|
|
55
|
+
line = f" task {t['id']} [{t['status']}] cost={t['cost']}"
|
|
56
|
+
if t["worker_id"]:
|
|
57
|
+
line += f" worker={t['worker_id']}"
|
|
58
|
+
print(line)
|
|
59
|
+
if t["result"] is not None:
|
|
60
|
+
print(f" result: {json.dumps(t['result'])}")
|
|
61
|
+
if t["error"]:
|
|
62
|
+
print(f" error: {t['error']}")
|