nodeview-slurm 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.
- nodeview/__init__.py +3 -0
- nodeview/__main__.py +32 -0
- nodeview/analysis.py +298 -0
- nodeview/app.py +423 -0
- nodeview/app.tcss +35 -0
- nodeview/config.py +61 -0
- nodeview/i18n.py +455 -0
- nodeview/manual.py +176 -0
- nodeview/nodes.py +318 -0
- nodeview/palette.py +325 -0
- nodeview/profile.py +284 -0
- nodeview/render.py +688 -0
- nodeview/snapshot.py +72 -0
- nodeview/source.py +306 -0
- nodeview_slurm-0.1.0.dist-info/METADATA +41 -0
- nodeview_slurm-0.1.0.dist-info/RECORD +18 -0
- nodeview_slurm-0.1.0.dist-info/WHEEL +4 -0
- nodeview_slurm-0.1.0.dist-info/entry_points.txt +2 -0
nodeview/nodes.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Full node readout from `scontrol -d -o show node`.
|
|
2
|
+
|
|
3
|
+
nodeocc stops at sinfo and misses a lot: the actual GPU model (which here
|
|
4
|
+
lives in the features, not in GRES), the indices of busy GPUs, allocated RAM
|
|
5
|
+
versus truly free RAM, CPU load, partitions, scheduling weight. A single call
|
|
6
|
+
returns all of it, as key=value pairs.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from .i18n import t
|
|
16
|
+
|
|
17
|
+
# "NodeName=x CPUAlloc=0 AllocTRES=cpu=12,mem=96G,gres/gpu=3 ..." -> dict.
|
|
18
|
+
# Careful: some values contain '=' (AllocTRES, CfgTRES), so each token is split
|
|
19
|
+
# on its FIRST '=', not on all of them. The only fields with spaces inside are
|
|
20
|
+
# OS= and Reason=: the first isn't needed, the second is extracted separately.
|
|
21
|
+
_REASON = re.compile(r'\bReason=(.*?)(?=\s+\w+=|$)')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _kv(line: str) -> dict:
|
|
25
|
+
out = {}
|
|
26
|
+
for token in line.split():
|
|
27
|
+
if '=' not in token:
|
|
28
|
+
continue
|
|
29
|
+
key, value = token.split('=', 1)
|
|
30
|
+
out.setdefault(key, value)
|
|
31
|
+
match = _REASON.search(line)
|
|
32
|
+
if match:
|
|
33
|
+
out['Reason'] = match.group(1).strip()
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _int(value, default=0) -> int:
|
|
38
|
+
try:
|
|
39
|
+
return int(str(value).strip())
|
|
40
|
+
except (TypeError, ValueError):
|
|
41
|
+
return default
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _float(value, default=0.0) -> float:
|
|
45
|
+
try:
|
|
46
|
+
return float(str(value).strip())
|
|
47
|
+
except (TypeError, ValueError):
|
|
48
|
+
return default
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _gpu_count(gres: str) -> int:
|
|
52
|
+
"""'gpu:5(S:0),tmpfs:90198M' -> 5 ; 'gpu:(null):3(IDX:1-2,4)' -> 3"""
|
|
53
|
+
if not gres:
|
|
54
|
+
return 0
|
|
55
|
+
for token in gres.split(','):
|
|
56
|
+
token = token.strip()
|
|
57
|
+
if not token.startswith('gpu'):
|
|
58
|
+
continue
|
|
59
|
+
core = token.split('(')[0]
|
|
60
|
+
parts = [p for p in core.split(':') if p and p != 'null']
|
|
61
|
+
for p in reversed(parts):
|
|
62
|
+
if p.isdigit():
|
|
63
|
+
return int(p)
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _gpu_indices(gres_used: str) -> list:
|
|
68
|
+
"""'gpu:(null):3(IDX:1-2,4)' -> [1, 2, 4]"""
|
|
69
|
+
if not gres_used:
|
|
70
|
+
return []
|
|
71
|
+
# Slurm writes 'gpu:(null):3(IDX:1-2,4)': between 'gpu:' and '(IDX:' sits
|
|
72
|
+
# the model, which here is (null), so skip it without swallowing the IDX
|
|
73
|
+
match = re.search(r'gpu:.*?\(IDX:([^)]*)\)', gres_used)
|
|
74
|
+
if not match:
|
|
75
|
+
return []
|
|
76
|
+
out = []
|
|
77
|
+
for chunk in match.group(1).split(','):
|
|
78
|
+
chunk = chunk.strip()
|
|
79
|
+
if chunk in ('', 'N/A'):
|
|
80
|
+
continue
|
|
81
|
+
if '-' in chunk:
|
|
82
|
+
a, b = chunk.split('-', 1)
|
|
83
|
+
if a.isdigit() and b.isdigit():
|
|
84
|
+
out.extend(range(int(a), int(b) + 1))
|
|
85
|
+
elif chunk.isdigit():
|
|
86
|
+
out.append(int(chunk))
|
|
87
|
+
return sorted(set(out))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _model(features: str) -> str:
|
|
91
|
+
"""'gpu_RTX_A5000_24G' -> 'RTX A5000 24G'; several features -> first gpu_*"""
|
|
92
|
+
for feat in (features or '').split(','):
|
|
93
|
+
feat = feat.strip()
|
|
94
|
+
if feat.startswith('gpu_'):
|
|
95
|
+
return feat[4:].replace('_', ' ')
|
|
96
|
+
first = (features or '').split(',')[0].strip()
|
|
97
|
+
return t('na') if first in ('', '(null)', 'null') else first
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _mem_mb(value: str) -> float:
|
|
101
|
+
"""'96G' / '98304' / '96000M' -> MB"""
|
|
102
|
+
value = (value or '').strip()
|
|
103
|
+
if not value:
|
|
104
|
+
return 0.0
|
|
105
|
+
mult = {'K': 1 / 1024, 'M': 1, 'G': 1024, 'T': 1024 * 1024}
|
|
106
|
+
if value[-1].upper() in mult:
|
|
107
|
+
return _float(value[:-1]) * mult[value[-1].upper()]
|
|
108
|
+
return _float(value)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _alloc_tres(tres: str) -> dict:
|
|
112
|
+
"""'cpu=12,mem=96G,gres/gpu=3' -> {'cpu': 12.0, 'mem': 98304.0, 'gpu': 3.0}"""
|
|
113
|
+
out = {}
|
|
114
|
+
for item in (tres or '').split(','):
|
|
115
|
+
if '=' not in item:
|
|
116
|
+
continue
|
|
117
|
+
key, value = item.split('=', 1)
|
|
118
|
+
key = key.strip()
|
|
119
|
+
if key == 'mem':
|
|
120
|
+
out['mem'] = _mem_mb(value)
|
|
121
|
+
elif key == 'cpu':
|
|
122
|
+
out['cpu'] = _float(value)
|
|
123
|
+
elif key in ('gres/gpu', 'gpu'):
|
|
124
|
+
out['gpu'] = _float(value)
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class NodeSpec:
|
|
130
|
+
"""Everything Slurm says about a node."""
|
|
131
|
+
name: str
|
|
132
|
+
state: str # IDLE, MIXED, ALLOCATED, DOWN, DRAINED...
|
|
133
|
+
reason: Optional[str]
|
|
134
|
+
partitions: tuple = ()
|
|
135
|
+
|
|
136
|
+
gpu_model: str = field(default_factory=lambda: t('na'))
|
|
137
|
+
gpus_total: int = 0
|
|
138
|
+
gpus_used: int = 0
|
|
139
|
+
gpu_indices_used: tuple = ()
|
|
140
|
+
|
|
141
|
+
cpus_total: int = 0
|
|
142
|
+
cpus_alloc: int = 0
|
|
143
|
+
cpu_load: float = 0.0
|
|
144
|
+
|
|
145
|
+
mem_total_mb: float = 0.0
|
|
146
|
+
mem_alloc_mb: float = 0.0
|
|
147
|
+
mem_free_mb: float = 0.0 # truly free (system RAM), not "unallocated"
|
|
148
|
+
|
|
149
|
+
sockets: int = 0
|
|
150
|
+
cores_per_socket: int = 0
|
|
151
|
+
threads_per_core: int = 0
|
|
152
|
+
arch: str = ''
|
|
153
|
+
weight: int = 0
|
|
154
|
+
tmp_disk_mb: float = 0.0
|
|
155
|
+
boot_time: str = ''
|
|
156
|
+
slurm_version: str = ''
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def gpus_free(self) -> int:
|
|
160
|
+
return max(0, self.gpus_total - self.gpus_used)
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def cpus_free(self) -> int:
|
|
164
|
+
return max(0, self.cpus_total - self.cpus_alloc)
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def mem_unalloc_mb(self) -> float:
|
|
168
|
+
return max(0.0, self.mem_total_mb - self.mem_alloc_mb)
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def base_state(self) -> str:
|
|
172
|
+
return self.state.split('+')[0].split('*')[0].upper()
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def healthy(self) -> bool:
|
|
176
|
+
bad = ('DOWN', 'DRAIN', 'DRAINED', 'DRAINING', 'FAIL', 'FAILING',
|
|
177
|
+
'MAINT', 'INVAL', 'UNKNOWN', 'NOT_RESPONDING')
|
|
178
|
+
return not any(b in self.state.upper() for b in bad)
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def schedulable(self) -> bool:
|
|
182
|
+
return self.healthy and self.gpus_free > 0
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def gpu_load(self) -> float:
|
|
186
|
+
return self.gpus_used / self.gpus_total if self.gpus_total else 0.0
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def cpu_alloc_load(self) -> float:
|
|
190
|
+
return self.cpus_alloc / self.cpus_total if self.cpus_total else 0.0
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def mem_load(self) -> float:
|
|
194
|
+
return self.mem_alloc_mb / self.mem_total_mb if self.mem_total_mb else 0.0
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def cpu_real_load(self) -> float:
|
|
198
|
+
"""Actual load over total CPUs: exposes jobs that request but don't use."""
|
|
199
|
+
return self.cpu_load / self.cpus_total if self.cpus_total else 0.0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def short_name(name: str) -> str:
|
|
203
|
+
"""Same normalization as nodeocc, so joblet node names match."""
|
|
204
|
+
return name.replace('aimagelab-srv-', '').replace('ailb-login-', '')
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _expand_nodelist(spec: str) -> list:
|
|
208
|
+
"""'huber' -> ['huber'] ; 'ailb-login-0[2-3],nico' -> ['ailb-login-02', ...]"""
|
|
209
|
+
out, buf, depth = [], '', 0
|
|
210
|
+
for ch in spec: # commas inside [] don't split
|
|
211
|
+
if ch == '[':
|
|
212
|
+
depth += 1
|
|
213
|
+
elif ch == ']':
|
|
214
|
+
depth -= 1
|
|
215
|
+
if ch == ',' and depth == 0:
|
|
216
|
+
out.append(buf)
|
|
217
|
+
buf = ''
|
|
218
|
+
else:
|
|
219
|
+
buf += ch
|
|
220
|
+
if buf:
|
|
221
|
+
out.append(buf)
|
|
222
|
+
|
|
223
|
+
names = []
|
|
224
|
+
for item in out:
|
|
225
|
+
item = item.strip()
|
|
226
|
+
if '[' not in item:
|
|
227
|
+
if item:
|
|
228
|
+
names.append(item)
|
|
229
|
+
continue
|
|
230
|
+
prefix, rest = item.split('[', 1)
|
|
231
|
+
body, suffix = rest.split(']', 1) if ']' in rest else (rest, '')
|
|
232
|
+
for part in body.split(','):
|
|
233
|
+
if '-' in part:
|
|
234
|
+
a, b = part.split('-', 1)
|
|
235
|
+
if a.isdigit() and b.isdigit():
|
|
236
|
+
width = len(a)
|
|
237
|
+
names.extend(f"{prefix}{i:0{width}d}{suffix}"
|
|
238
|
+
for i in range(int(a), int(b) + 1))
|
|
239
|
+
elif part:
|
|
240
|
+
names.append(f"{prefix}{part}{suffix}")
|
|
241
|
+
return names
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def read_gpu_owners() -> dict:
|
|
245
|
+
"""{node: {GPU index: user}}.
|
|
246
|
+
|
|
247
|
+
`scontrol -d show job` reports, for each job and each node it occupies,
|
|
248
|
+
the exact indices of the assigned GPUs: it's the only way to know not just
|
|
249
|
+
which GPUs are in use, but by whom. A single call for all jobs, with -o
|
|
250
|
+
putting each job on one line; multi-node jobs have several
|
|
251
|
+
`Nodes=... GRES=...` blocks on the same line, so all of them are scanned.
|
|
252
|
+
"""
|
|
253
|
+
try:
|
|
254
|
+
out = subprocess.run(['scontrol', '-d', '-o', 'show', 'job'],
|
|
255
|
+
capture_output=True, text=True, timeout=20)
|
|
256
|
+
except (OSError, subprocess.SubprocessError):
|
|
257
|
+
return {}
|
|
258
|
+
if out.returncode != 0:
|
|
259
|
+
return {}
|
|
260
|
+
|
|
261
|
+
owners = {}
|
|
262
|
+
block = re.compile(r'Nodes=(\S+)\s+CPU_IDs=\S+\s+Mem=\S+\s+GRES=(\S+)')
|
|
263
|
+
for line in out.stdout.splitlines():
|
|
264
|
+
if 'JobState=RUNNING' not in line and 'JobState=COMPLETING' not in line:
|
|
265
|
+
continue
|
|
266
|
+
who = re.search(r'UserId=([^(\s]+)', line)
|
|
267
|
+
if not who:
|
|
268
|
+
continue
|
|
269
|
+
user = who.group(1)
|
|
270
|
+
for nodes_spec, gres in block.findall(line):
|
|
271
|
+
indices = _gpu_indices(gres)
|
|
272
|
+
if not indices:
|
|
273
|
+
continue
|
|
274
|
+
for node in _expand_nodelist(nodes_spec):
|
|
275
|
+
slot = owners.setdefault(short_name(node), {})
|
|
276
|
+
for idx in indices:
|
|
277
|
+
slot.setdefault(idx, user)
|
|
278
|
+
return owners
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def read_nodes() -> list:
|
|
282
|
+
out = subprocess.run(['scontrol', '-d', '-o', 'show', 'node'],
|
|
283
|
+
capture_output=True, text=True, timeout=20)
|
|
284
|
+
if out.returncode != 0:
|
|
285
|
+
raise RuntimeError(t('src.scontrol_failed', err=out.stderr.strip()))
|
|
286
|
+
|
|
287
|
+
nodes = []
|
|
288
|
+
for line in out.stdout.splitlines():
|
|
289
|
+
if not line.strip().startswith('NodeName='):
|
|
290
|
+
continue
|
|
291
|
+
d = _kv(line)
|
|
292
|
+
alloc = _alloc_tres(d.get('AllocTRES', ''))
|
|
293
|
+
reason = d.get('Reason', '').strip()
|
|
294
|
+
nodes.append(NodeSpec(
|
|
295
|
+
name=short_name(d.get('NodeName', '?')),
|
|
296
|
+
state=d.get('State', '?'),
|
|
297
|
+
reason=None if reason in ('', 'none', 'N/A') else reason,
|
|
298
|
+
partitions=tuple(p for p in d.get('Partitions', '').split(',') if p),
|
|
299
|
+
gpu_model=_model(d.get('AvailableFeatures', '')),
|
|
300
|
+
gpus_total=_gpu_count(d.get('Gres', '')),
|
|
301
|
+
gpus_used=int(alloc.get('gpu', _gpu_count(d.get('GresUsed', '')))),
|
|
302
|
+
gpu_indices_used=tuple(_gpu_indices(d.get('GresUsed', ''))),
|
|
303
|
+
cpus_total=_int(d.get('CPUTot')),
|
|
304
|
+
cpus_alloc=_int(d.get('CPUAlloc')),
|
|
305
|
+
cpu_load=_float(d.get('CPULoad')),
|
|
306
|
+
mem_total_mb=_float(d.get('RealMemory')),
|
|
307
|
+
mem_alloc_mb=_float(d.get('AllocMem')),
|
|
308
|
+
mem_free_mb=_float(d.get('FreeMem')),
|
|
309
|
+
sockets=_int(d.get('Sockets')),
|
|
310
|
+
cores_per_socket=_int(d.get('CoresPerSocket')),
|
|
311
|
+
threads_per_core=_int(d.get('ThreadsPerCore')),
|
|
312
|
+
arch=d.get('Arch', ''),
|
|
313
|
+
weight=_int(d.get('Weight')),
|
|
314
|
+
tmp_disk_mb=_float(d.get('TmpDisk')),
|
|
315
|
+
boot_time=d.get('BootTime', ''),
|
|
316
|
+
slurm_version=d.get('Version', ''),
|
|
317
|
+
))
|
|
318
|
+
return nodes
|
nodeview/palette.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Color palette.
|
|
2
|
+
|
|
3
|
+
Color here carries information, not decoration. Four distinct jobs, never
|
|
4
|
+
mixed:
|
|
5
|
+
|
|
6
|
+
- **categorical** -> identity: who occupies a GPU. Fixed order, never cycled:
|
|
7
|
+
the eighth user is the last with a color of their own; from the ninth on
|
|
8
|
+
they fall into "others". Color never travels alone, the name is always next
|
|
9
|
+
to it.
|
|
10
|
+
- **sequential** -> quantity: how full a node is. A single hue, light to dark.
|
|
11
|
+
No rainbows.
|
|
12
|
+
- **status** -> condition: free / nearly full / full / broken. Reserved, never
|
|
13
|
+
reused as "series 5", always paired with an icon and a word.
|
|
14
|
+
- **ink** -> text and borders. Numbers and labels never take the series
|
|
15
|
+
color.
|
|
16
|
+
|
|
17
|
+
The values come from the visualization guide's reference palette and were run
|
|
18
|
+
through the validator for both backgrounds (lightness band, minimum chroma,
|
|
19
|
+
color-blindness separation, contrast): all checks pass. On a light background
|
|
20
|
+
three hues stay below 3:1 contrast, which is why user names are always
|
|
21
|
+
written next to the color.
|
|
22
|
+
|
|
23
|
+
Terminals without truecolor support get the 256- or 16-color approximation:
|
|
24
|
+
rich handles that on its own.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import colorsys
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Palette:
|
|
34
|
+
name: str
|
|
35
|
+
|
|
36
|
+
# identity: fixed order, 8 slots
|
|
37
|
+
categorical: tuple
|
|
38
|
+
other: str
|
|
39
|
+
|
|
40
|
+
# quantity: a single hue, from light to dark
|
|
41
|
+
sequential: tuple
|
|
42
|
+
|
|
43
|
+
# status: reserved
|
|
44
|
+
good: str
|
|
45
|
+
warning: str
|
|
46
|
+
serious: str
|
|
47
|
+
critical: str
|
|
48
|
+
|
|
49
|
+
# ink
|
|
50
|
+
ink: str
|
|
51
|
+
ink_soft: str
|
|
52
|
+
ink_muted: str
|
|
53
|
+
rule: str
|
|
54
|
+
|
|
55
|
+
def slot(self, index: int) -> str:
|
|
56
|
+
"""Color of slot `index`; past the eighth, the neutral 'others' color."""
|
|
57
|
+
if index < 0 or index >= len(self.categorical):
|
|
58
|
+
return self.other
|
|
59
|
+
return self.categorical[index]
|
|
60
|
+
|
|
61
|
+
def fill(self, ratio: float) -> str:
|
|
62
|
+
"""Step of the sequential ramp for a quantity between 0 and 1."""
|
|
63
|
+
if not self.sequential:
|
|
64
|
+
return self.ink
|
|
65
|
+
steps = len(self.sequential)
|
|
66
|
+
idx = min(steps - 1, max(0, int(round(ratio * (steps - 1)))))
|
|
67
|
+
return self.sequential[idx]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
DARK = Palette(
|
|
71
|
+
name='dark',
|
|
72
|
+
categorical=('#3987e5', '#d95926', '#199e70', '#c98500',
|
|
73
|
+
'#d55181', '#008300', '#9085e9', '#e66767'),
|
|
74
|
+
other='#898781',
|
|
75
|
+
sequential=('#cde2fb', '#9ec5f4', '#6da7ec', '#3987e5', '#256abf', '#184f95'),
|
|
76
|
+
good='#0ca30c',
|
|
77
|
+
warning='#fab219',
|
|
78
|
+
serious='#ec835a',
|
|
79
|
+
critical='#d03b3b',
|
|
80
|
+
ink='#ffffff',
|
|
81
|
+
ink_soft='#c3c2b7',
|
|
82
|
+
ink_muted='#898781',
|
|
83
|
+
rule='#383835',
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
LIGHT = Palette(
|
|
87
|
+
name='light',
|
|
88
|
+
categorical=('#2a78d6', '#eb6834', '#1baf7a', '#eda100',
|
|
89
|
+
'#e87ba4', '#008300', '#4a3aa7', '#e34948'),
|
|
90
|
+
other='#898781',
|
|
91
|
+
sequential=('#cde2fb', '#9ec5f4', '#6da7ec', '#3987e5', '#256abf', '#184f95'),
|
|
92
|
+
good='#006300',
|
|
93
|
+
warning='#b37400',
|
|
94
|
+
serious='#c4562a',
|
|
95
|
+
critical='#c22a2a',
|
|
96
|
+
ink='#0b0b0b',
|
|
97
|
+
ink_soft='#52514e',
|
|
98
|
+
ink_muted='#898781',
|
|
99
|
+
rule='#c3c2b7',
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
_RAMP = ('#cde2fb', '#9ec5f4', '#6da7ec', '#3987e5', '#256abf', '#184f95')
|
|
103
|
+
|
|
104
|
+
# The active palette. The app swaps it when you change theme; --once uses the
|
|
105
|
+
# dark one, which is right for the vast majority of terminals.
|
|
106
|
+
_active = DARK
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def active() -> Palette:
|
|
110
|
+
return _active
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def use(palette: Palette) -> None:
|
|
114
|
+
global _active
|
|
115
|
+
_active = palette
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def for_theme(dark: bool) -> Palette:
|
|
119
|
+
"""Fallback when there is no theme to measure."""
|
|
120
|
+
return DARK if dark else LIGHT
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def default() -> Palette:
|
|
124
|
+
"""Palette for static mode: the terminal background can't be queried, so
|
|
125
|
+
textual-dark's is assumed."""
|
|
126
|
+
try:
|
|
127
|
+
from textual.theme import BUILTIN_THEMES
|
|
128
|
+
return from_theme(BUILTIN_THEMES['textual-dark'])
|
|
129
|
+
except Exception:
|
|
130
|
+
return DARK
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# Shape on top of color. Eight hues can't all be told apart: measured in
|
|
134
|
+
# pairs, the worst drops to ΔE 1.6 under deuteranopia, and on the GPU dots any
|
|
135
|
+
# two users can end up side by side. Shape is the secondary encoding the guide
|
|
136
|
+
# calls for in these cases.
|
|
137
|
+
#
|
|
138
|
+
# The pairing isn't arbitrary: of all the ways to pair eight slots into four
|
|
139
|
+
# groups, it's the one that maximizes the minimum separation between colors
|
|
140
|
+
# sharing a shape. That way the worst pair with the same shape rises to
|
|
141
|
+
# ΔE 11.5, and the weak pairs always get different shapes.
|
|
142
|
+
SHAPES = ("●", "●", "◆", "■", "■", "◆", "▲", "▲")
|
|
143
|
+
SHAPE_OTHER = "●"
|
|
144
|
+
SHAPE_FREE = "○"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Occupants:
|
|
148
|
+
"""Assigns identity colors to users.
|
|
149
|
+
|
|
150
|
+
Fixed, stable order: whoever holds the most GPUs gets the lowest slot, and
|
|
151
|
+
you always get the first. Beyond the first eight you fall into "others":
|
|
152
|
+
never generate a ninth hue.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
LIMIT = 8
|
|
156
|
+
|
|
157
|
+
def __init__(self, counts: dict, me: str | None = None) -> None:
|
|
158
|
+
ranked = [u for u, _ in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))]
|
|
159
|
+
if me in ranked:
|
|
160
|
+
ranked.remove(me)
|
|
161
|
+
ranked.insert(0, me)
|
|
162
|
+
self._slots = {user: i for i, user in enumerate(ranked[:self.LIMIT])}
|
|
163
|
+
self.me = me
|
|
164
|
+
self.overflow = ranked[self.LIMIT:]
|
|
165
|
+
|
|
166
|
+
def style(self, user: str) -> str:
|
|
167
|
+
pal = active()
|
|
168
|
+
return pal.slot(self._slots[user]) if user in self._slots else pal.other
|
|
169
|
+
|
|
170
|
+
def shape(self, user: str) -> str:
|
|
171
|
+
"""The user's glyph: the encoding that holds up even without color."""
|
|
172
|
+
idx = self._slots.get(user)
|
|
173
|
+
return SHAPES[idx] if idx is not None else SHAPE_OTHER
|
|
174
|
+
|
|
175
|
+
def named(self) -> list:
|
|
176
|
+
"""(user, color, shape) in slot order, for the legend."""
|
|
177
|
+
return [(u, self.style(u), self.shape(u))
|
|
178
|
+
for u, _ in sorted(self._slots.items(), key=lambda kv: kv[1])]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# --------------------------------------------------------------------------
|
|
182
|
+
# adapting to the actual background
|
|
183
|
+
# --------------------------------------------------------------------------
|
|
184
|
+
# A fixed palette isn't enough: Textual themes have very different
|
|
185
|
+
# backgrounds, and "dark" doesn't say how dark. Nord's panel is #434C5E,
|
|
186
|
+
# textual-dark's is #242F38: on one the dark ramp vanishes, on the other it
|
|
187
|
+
# doesn't. So the data hues stay the validated ones, but the surroundings (ink,
|
|
188
|
+
# rules) and the ramp direction are derived from the actual background.
|
|
189
|
+
|
|
190
|
+
def _rgb(value: str) -> tuple:
|
|
191
|
+
value = str(value).lstrip('#')[:6]
|
|
192
|
+
return tuple(int(value[i:i + 2], 16) for i in (0, 2, 4))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _hex(rgb: tuple) -> str:
|
|
196
|
+
return '#%02x%02x%02x' % tuple(max(0, min(255, int(round(c)))) for c in rgb)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _luminance(value: str) -> float:
|
|
200
|
+
"""WCAG relative luminance."""
|
|
201
|
+
def channel(c):
|
|
202
|
+
c = c / 255
|
|
203
|
+
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
|
204
|
+
r, g, b = (channel(c) for c in _rgb(value))
|
|
205
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def contrast(a: str, b: str) -> float:
|
|
209
|
+
la, lb = _luminance(a), _luminance(b)
|
|
210
|
+
hi, lo = max(la, lb), min(la, lb)
|
|
211
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def blend(a: str, b: str, t: float) -> str:
|
|
215
|
+
"""t=1 all a, t=0 all b."""
|
|
216
|
+
ra, rb = _rgb(a), _rgb(b)
|
|
217
|
+
return _hex(tuple(x * t + y * (1 - t) for x, y in zip(ra, rb)))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def toward(ink: str, surface: str, target: float) -> str:
|
|
221
|
+
"""The color closest to the background that still reaches `target` contrast.
|
|
222
|
+
|
|
223
|
+
Blending by a fixed percentage doesn't work: some themes (solarized)
|
|
224
|
+
already have low-contrast text, and dimming it further makes it unreadable.
|
|
225
|
+
Instead this searches for the right point, and if the theme's text doesn't
|
|
226
|
+
reach the target it is used as is.
|
|
227
|
+
"""
|
|
228
|
+
if contrast(ink, surface) <= target:
|
|
229
|
+
return ink
|
|
230
|
+
lo, hi = 0.0, 1.0
|
|
231
|
+
for _ in range(16):
|
|
232
|
+
mid = (lo + hi) / 2
|
|
233
|
+
if contrast(blend(ink, surface, mid), surface) >= target:
|
|
234
|
+
hi = mid
|
|
235
|
+
else:
|
|
236
|
+
lo = mid
|
|
237
|
+
return blend(ink, surface, hi)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def lift(color: str, surface: str, target: float = 3.0) -> str:
|
|
241
|
+
"""Brings `color` to `target` contrast against the background, keeping its hue.
|
|
242
|
+
|
|
243
|
+
Status colors are reserved and recognized by hue: green free, red broken.
|
|
244
|
+
On a mid-lightness background like nord's (#434C5E) both drown, but
|
|
245
|
+
changing their hue would turn them into something else. So only lightness
|
|
246
|
+
moves, lighter on a dark background and darker on a light one, until the
|
|
247
|
+
threshold is crossed.
|
|
248
|
+
"""
|
|
249
|
+
if contrast(color, surface) >= target:
|
|
250
|
+
return color
|
|
251
|
+
r, g, b = (c / 255 for c in _rgb(color))
|
|
252
|
+
h, l, sat = colorsys.rgb_to_hls(r, g, b)
|
|
253
|
+
up = _luminance(surface) < 0.4 # dark background -> lighten
|
|
254
|
+
lo, hi = (l, 1.0) if up else (0.0, l)
|
|
255
|
+
|
|
256
|
+
best = color
|
|
257
|
+
for _ in range(20):
|
|
258
|
+
mid = (lo + hi) / 2
|
|
259
|
+
cand = _hex(tuple(c * 255 for c in colorsys.hls_to_rgb(h, mid, sat)))
|
|
260
|
+
if contrast(cand, surface) >= target:
|
|
261
|
+
best = cand
|
|
262
|
+
if up:
|
|
263
|
+
hi = mid
|
|
264
|
+
else:
|
|
265
|
+
lo = mid
|
|
266
|
+
else:
|
|
267
|
+
if up:
|
|
268
|
+
lo = mid
|
|
269
|
+
else:
|
|
270
|
+
hi = mid
|
|
271
|
+
return best
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _ramp_for(surface: str) -> tuple:
|
|
275
|
+
"""Ramp ordered so that 'fuller' is always 'more visible'.
|
|
276
|
+
|
|
277
|
+
The guide's rule is a single hue, monotonic in lightness; the direction,
|
|
278
|
+
though, depends on the background. On a dark background a ramp that ends
|
|
279
|
+
dark makes a full meter look duller than a half-empty one: that's a
|
|
280
|
+
misreading of the data, not an aesthetic quirk. Steps too close to the
|
|
281
|
+
background are dropped and the rest sorted by increasing contrast.
|
|
282
|
+
"""
|
|
283
|
+
steps = [c for c in _RAMP if contrast(c, surface) >= 1.35]
|
|
284
|
+
if len(steps) < 4:
|
|
285
|
+
steps = sorted(_RAMP, key=lambda c: contrast(c, surface), reverse=True)[:4]
|
|
286
|
+
return tuple(sorted(steps, key=lambda c: contrast(c, surface)))
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def from_theme(theme) -> Palette:
|
|
290
|
+
"""Builds the palette for a Textual theme by measuring its background."""
|
|
291
|
+
try:
|
|
292
|
+
resolved = theme.to_color_system().generate()
|
|
293
|
+
except Exception:
|
|
294
|
+
resolved = {}
|
|
295
|
+
surface = str(resolved.get('panel') or resolved.get('surface')
|
|
296
|
+
or getattr(theme, 'panel', None) or '#242F38')
|
|
297
|
+
ink = str(resolved.get('foreground') or getattr(theme, 'foreground', None) or '#e0e0e0')
|
|
298
|
+
# ansi-* themes leave colors to the terminal and expose no hex values:
|
|
299
|
+
# there's no background to measure there, so fall back to the theme's label
|
|
300
|
+
measurable = surface.startswith('#') and ink.startswith('#')
|
|
301
|
+
if not measurable:
|
|
302
|
+
dark_theme = bool(getattr(theme, 'dark', True))
|
|
303
|
+
surface = '#242F38' if dark_theme else '#D0D0D0'
|
|
304
|
+
ink = '#e0e0e0' if dark_theme else '#0b0b0b'
|
|
305
|
+
|
|
306
|
+
# where there is a real background, it decides, not the "dark" label
|
|
307
|
+
dark = _luminance(surface) < 0.25
|
|
308
|
+
base = DARK if dark else LIGHT
|
|
309
|
+
|
|
310
|
+
return Palette(
|
|
311
|
+
name=f"{getattr(theme, 'name', '?')} ({'dark' if dark else 'light'})",
|
|
312
|
+
categorical=base.categorical,
|
|
313
|
+
other=toward(ink, surface, 2.5),
|
|
314
|
+
sequential=_ramp_for(surface),
|
|
315
|
+
good=lift(base.good, surface),
|
|
316
|
+
warning=lift(base.warning, surface),
|
|
317
|
+
serious=lift(base.serious, surface),
|
|
318
|
+
critical=lift(base.critical, surface),
|
|
319
|
+
ink=ink,
|
|
320
|
+
# targets: readable secondary text, distinguishable labels,
|
|
321
|
+
# rules that are visible but don't grab the eye
|
|
322
|
+
ink_soft=toward(ink, surface, 4.5),
|
|
323
|
+
ink_muted=toward(ink, surface, 3.0),
|
|
324
|
+
rule=toward(ink, surface, 1.6),
|
|
325
|
+
)
|