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/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
+ )