rosetta-sql 1.0.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.
rosetta/flamegraph.py ADDED
@@ -0,0 +1,630 @@
1
+ """Flame graph capture and SVG generation for Rosetta benchmark profiling.
2
+
3
+ This module provides:
4
+ 1. ``PerfProfiler`` – captures CPU stack traces via ``perf record`` / ``perf script``
5
+ for a running mysqld process while a SQL query is being executed.
6
+ 2. ``collapse_stacks()`` – folds raw ``perf script`` output into the
7
+ "folded stacks" format (``func1;func2;func3 count``).
8
+ 3. ``flamegraph_svg()`` – renders folded stacks into an interactive SVG
9
+ flame graph (pure Python, no external dependencies).
10
+
11
+ Requirements:
12
+ - ``perf`` installed on the server (``linux-tools-*`` or ``perf-tools``).
13
+ - The mysqld process accessible locally (same machine).
14
+ - Sufficient privileges to run ``perf record -g -p <pid>``.
15
+ """
16
+
17
+ import logging
18
+ import os
19
+ import re
20
+ import shutil
21
+ import subprocess
22
+ import tempfile
23
+ import time
24
+ from collections import defaultdict
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Dict, List, Optional, Tuple
28
+
29
+ log = logging.getLogger("rosetta")
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Data structures
34
+ # ---------------------------------------------------------------------------
35
+
36
+ @dataclass
37
+ class FlameGraphData:
38
+ """Holds the profiling result for a single capture session."""
39
+ query_name: str = ""
40
+ svg_content: str = ""
41
+ folded_stacks: str = ""
42
+ sample_count: int = 0
43
+ duration_ms: float = 0.0
44
+ error: str = ""
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # perf availability check
49
+ # ---------------------------------------------------------------------------
50
+
51
+ def check_perf_available() -> Tuple[bool, str]:
52
+ """Check if ``perf`` is installed and usable.
53
+
54
+ Returns:
55
+ (ok, message) tuple.
56
+ """
57
+ perf_path = shutil.which("perf")
58
+ if not perf_path:
59
+ return False, "perf not found in PATH. Install linux-tools or perf-tools."
60
+
61
+ try:
62
+ proc = subprocess.run(
63
+ ["perf", "--version"],
64
+ capture_output=True, text=True, timeout=5,
65
+ )
66
+ if proc.returncode == 0:
67
+ return True, proc.stdout.strip()
68
+ return False, f"perf returned exit code {proc.returncode}: {proc.stderr.strip()}"
69
+ except Exception as e:
70
+ return False, f"perf check failed: {e}"
71
+
72
+
73
+ def find_mysqld_pid(port: int = 3306) -> Optional[int]:
74
+ """Find the PID of the mysqld process listening on *port*.
75
+
76
+ Strategy:
77
+ 1. Try ``ss -tlnp`` to find the process bound to *port*.
78
+ 2. Fall back to ``pidof mysqld``.
79
+ """
80
+ # Strategy 1: ss -tlnp
81
+ try:
82
+ proc = subprocess.run(
83
+ ["ss", "-tlnp"],
84
+ capture_output=True, text=True, timeout=5,
85
+ )
86
+ if proc.returncode == 0:
87
+ for line in proc.stdout.splitlines():
88
+ # Match lines containing the target port
89
+ if f":{port}" in line:
90
+ # Extract pid from "pid=12345,"
91
+ m = re.search(r'pid=(\d+)', line)
92
+ if m:
93
+ return int(m.group(1))
94
+ except Exception:
95
+ pass
96
+
97
+ # Strategy 2: pidof mysqld
98
+ try:
99
+ proc = subprocess.run(
100
+ ["pidof", "mysqld"],
101
+ capture_output=True, text=True, timeout=5,
102
+ )
103
+ if proc.returncode == 0 and proc.stdout.strip():
104
+ # pidof may return multiple PIDs; take the first
105
+ pids = proc.stdout.strip().split()
106
+ return int(pids[0])
107
+ except Exception:
108
+ pass
109
+
110
+ return None
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # PerfProfiler – capture CPU stacks around SQL execution
115
+ # ---------------------------------------------------------------------------
116
+
117
+ class PerfProfiler:
118
+ """Profile a mysqld process using ``perf record`` during query execution.
119
+
120
+ Usage::
121
+
122
+ profiler = PerfProfiler(mysqld_pid=12345, perf_freq=99)
123
+
124
+ profiler.start() # starts perf record in background
125
+ cursor.execute(sql) # run the query
126
+ fg_data = profiler.stop() # stops perf, generates SVG
127
+ """
128
+
129
+ def __init__(
130
+ self,
131
+ mysqld_pid: int,
132
+ perf_freq: int = 99,
133
+ tmp_dir: Optional[str] = None,
134
+ ):
135
+ self.mysqld_pid = mysqld_pid
136
+ self.perf_freq = perf_freq
137
+ self._tmp_dir = tmp_dir or tempfile.mkdtemp(prefix="rosetta_perf_")
138
+ self._perf_data_path = os.path.join(self._tmp_dir, "perf.data")
139
+ self._perf_proc: Optional[subprocess.Popen] = None
140
+ self._start_time: float = 0.0
141
+
142
+ def start(self):
143
+ """Start ``perf record`` in the background."""
144
+ cmd = [
145
+ "perf", "record",
146
+ "-g", # call-graph (DWARF or fp)
147
+ "-F", str(self.perf_freq), # sampling frequency
148
+ "-p", str(self.mysqld_pid), # attach to mysqld
149
+ "-o", self._perf_data_path, # output file
150
+ "--",
151
+ ]
152
+
153
+ try:
154
+ self._perf_proc = subprocess.Popen(
155
+ cmd,
156
+ stdout=subprocess.DEVNULL,
157
+ stderr=subprocess.PIPE,
158
+ )
159
+ self._start_time = time.monotonic()
160
+ except Exception as e:
161
+ log.warning("Failed to start perf record: %s", e)
162
+ self._perf_proc = None
163
+
164
+ def stop(self, query_name: str = "") -> FlameGraphData:
165
+ """Stop ``perf record``, process output, and return flame graph data."""
166
+ result = FlameGraphData(query_name=query_name)
167
+
168
+ if self._perf_proc is None:
169
+ result.error = "perf record was not started"
170
+ return result
171
+
172
+ duration = time.monotonic() - self._start_time
173
+ result.duration_ms = duration * 1000.0
174
+
175
+ # Send SIGINT to perf to stop recording gracefully
176
+ try:
177
+ self._perf_proc.send_signal(2) # SIGINT
178
+ self._perf_proc.wait(timeout=10)
179
+ except subprocess.TimeoutExpired:
180
+ log.warning("perf record did not stop after SIGINT, killing")
181
+ self._perf_proc.kill()
182
+ try:
183
+ self._perf_proc.wait(timeout=5)
184
+ except subprocess.TimeoutExpired:
185
+ log.warning("perf record could not be killed")
186
+ result.error = "perf record could not be stopped"
187
+ return result
188
+ except Exception as e:
189
+ log.warning("Error stopping perf: %s", e)
190
+ result.error = f"Failed to stop perf: {e}"
191
+ return result
192
+
193
+ # Check if perf.data was created
194
+ if not os.path.isfile(self._perf_data_path):
195
+ stderr_out = ""
196
+ try:
197
+ stderr_out = self._perf_proc.stderr.read().decode(
198
+ "utf-8", errors="replace")
199
+ except Exception:
200
+ pass
201
+ result.error = f"perf.data not created. stderr: {stderr_out}"
202
+ return result
203
+
204
+ # Check perf.data size — skip processing if too large (>200MB)
205
+ # to avoid perf script hanging for minutes
206
+ try:
207
+ data_size = os.path.getsize(self._perf_data_path)
208
+ if data_size > 200 * 1024 * 1024:
209
+ result.error = (
210
+ f"perf.data too large ({data_size // (1024*1024)}MB), "
211
+ f"skipping flamegraph generation")
212
+ log.warning("[%s] %s", query_name, result.error)
213
+ return result
214
+ except OSError:
215
+ pass
216
+
217
+ # Run perf script to get human-readable stack traces
218
+ # Use a generous but bounded timeout to avoid hanging indefinitely
219
+ perf_script_timeout = max(60, min(300, int(duration * 3)))
220
+ try:
221
+ script_proc = subprocess.run(
222
+ ["perf", "script", "-i", self._perf_data_path],
223
+ capture_output=True, text=True, timeout=perf_script_timeout,
224
+ )
225
+ if script_proc.returncode != 0:
226
+ result.error = (
227
+ f"perf script failed (rc={script_proc.returncode}): "
228
+ f"{script_proc.stderr[:500]}")
229
+ return result
230
+
231
+ raw_script = script_proc.stdout
232
+ except subprocess.TimeoutExpired:
233
+ result.error = (
234
+ f"perf script timed out after {perf_script_timeout}s "
235
+ f"(perf.data may be too large)")
236
+ log.warning("[%s] %s", query_name, result.error)
237
+ return result
238
+ except Exception as e:
239
+ result.error = f"perf script error: {e}"
240
+ return result
241
+
242
+ # Collapse stacks
243
+ folded = collapse_stacks(raw_script)
244
+ result.folded_stacks = folded
245
+
246
+ # Count total samples
247
+ total_samples = 0
248
+ for line in folded.splitlines():
249
+ parts = line.rsplit(" ", 1)
250
+ if len(parts) == 2:
251
+ try:
252
+ total_samples += int(parts[1])
253
+ except ValueError:
254
+ pass
255
+ result.sample_count = total_samples
256
+
257
+ # Generate SVG
258
+ if total_samples > 0:
259
+ result.svg_content = flamegraph_svg(
260
+ folded, title=query_name or "Flame Graph")
261
+ else:
262
+ result.error = "No samples captured (query may have been too fast)"
263
+
264
+ return result
265
+
266
+ def cleanup(self):
267
+ """Remove temporary perf data files."""
268
+ try:
269
+ if os.path.isdir(self._tmp_dir):
270
+ shutil.rmtree(self._tmp_dir, ignore_errors=True)
271
+ except Exception:
272
+ pass
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # Stack collapsing (equivalent to stackcollapse-perf.pl)
277
+ # ---------------------------------------------------------------------------
278
+
279
+ def collapse_stacks(perf_script_output: str) -> str:
280
+ """Collapse ``perf script`` output into folded stack format.
281
+
282
+ Each output line: ``func1;func2;func3 count``
283
+
284
+ This is a pure-Python equivalent of Brendan Gregg's
285
+ ``stackcollapse-perf.pl`` script.
286
+ """
287
+ stacks: Dict[str, int] = defaultdict(int)
288
+ current_stack: List[str] = []
289
+ in_stack = False
290
+
291
+ for line in perf_script_output.splitlines():
292
+ line = line.rstrip()
293
+
294
+ # Empty line = end of a stack trace
295
+ if not line:
296
+ if current_stack:
297
+ # Reverse to get caller→callee order (bottom-up)
298
+ key = ";".join(reversed(current_stack))
299
+ stacks[key] += 1
300
+ current_stack = []
301
+ in_stack = False
302
+ continue
303
+
304
+ # Stack frame lines start with whitespace and a hex address
305
+ # Example: " ffffffff81234567 do_something+0x12 ([kernel.kallsyms])"
306
+ if line.startswith(("\t", " ")) and in_stack:
307
+ # Extract function name
308
+ stripped = line.strip()
309
+ # Format: <addr> <func+offset> (<module>)
310
+ parts = stripped.split(None, 2)
311
+ if len(parts) >= 2:
312
+ func = parts[1]
313
+ # Remove offset suffix: "func+0x1a" → "func"
314
+ plus_idx = func.find("+0x")
315
+ if plus_idx > 0:
316
+ func = func[:plus_idx]
317
+ current_stack.append(func)
318
+ continue
319
+
320
+ # Header line (process info): "mysqld 12345 [001] 12345.678901: ..."
321
+ # Indicates start of a new sample
322
+ if not line.startswith(("\t", " ")):
323
+ if current_stack:
324
+ key = ";".join(reversed(current_stack))
325
+ stacks[key] += 1
326
+ current_stack = []
327
+ in_stack = True
328
+ continue
329
+
330
+ # Handle last stack if file doesn't end with blank line
331
+ if current_stack:
332
+ key = ";".join(reversed(current_stack))
333
+ stacks[key] += 1
334
+
335
+ # Sort by count (descending) for consistent output
336
+ lines = []
337
+ for stack, count in sorted(stacks.items(), key=lambda x: -x[1]):
338
+ lines.append(f"{stack} {count}")
339
+
340
+ return "\n".join(lines)
341
+
342
+
343
+ # ---------------------------------------------------------------------------
344
+ # SVG Flame Graph renderer (pure Python)
345
+ # ---------------------------------------------------------------------------
346
+
347
+ # Color palette is now generated algorithmically in _flame_color().
348
+ # No static list needed.
349
+
350
+
351
+ def flamegraph_svg(
352
+ folded_stacks: str,
353
+ title: str = "Flame Graph",
354
+ width: int = 1200,
355
+ frame_height: int = 16,
356
+ font_size: float = 12.0,
357
+ min_width_px: float = 0.1,
358
+ ) -> str:
359
+ """Generate an interactive SVG flame graph from folded stack data.
360
+
361
+ Args:
362
+ folded_stacks: Folded stacks (``func1;func2 count`` per line).
363
+ title: Title shown at the top of the SVG.
364
+ width: Total SVG width in pixels.
365
+ frame_height: Height of each stack frame in pixels.
366
+ font_size: Font size for labels.
367
+ min_width_px: Minimum frame width to render (in pixels).
368
+
369
+ Returns:
370
+ SVG content as a string.
371
+ """
372
+ # Parse folded stacks
373
+ stack_counts: Dict[str, int] = {}
374
+ total_samples = 0
375
+ for line in folded_stacks.strip().splitlines():
376
+ line = line.strip()
377
+ if not line:
378
+ continue
379
+ parts = line.rsplit(" ", 1)
380
+ if len(parts) != 2:
381
+ continue
382
+ stack, count_str = parts
383
+ try:
384
+ count = int(count_str)
385
+ except ValueError:
386
+ continue
387
+ stack_counts[stack] = stack_counts.get(stack, 0) + count
388
+ total_samples += count
389
+
390
+ if total_samples == 0:
391
+ return ""
392
+
393
+ # Build a tree from the folded stacks
394
+ # Each node: {name, value, children: {name: node}}
395
+ root = {"name": "root", "value": 0, "children": {}}
396
+
397
+ for stack, count in stack_counts.items():
398
+ funcs = stack.split(";")
399
+ node = root
400
+ for func in funcs:
401
+ if func not in node["children"]:
402
+ node["children"][func] = {
403
+ "name": func, "value": 0, "children": {},
404
+ }
405
+ node = node["children"][func]
406
+ node["value"] += count
407
+
408
+ # Propagate values upward (each node's total = own + children)
409
+ def _total_value(node):
410
+ total = node["value"]
411
+ for child in node["children"].values():
412
+ total += _total_value(child)
413
+ return total
414
+
415
+ root_total = _total_value(root)
416
+
417
+ # Compute the maximum depth for SVG height
418
+ def _max_depth(node, depth=0):
419
+ if not node["children"]:
420
+ return depth
421
+ return max(_max_depth(c, depth + 1) for c in node["children"].values())
422
+
423
+ max_depth = _max_depth(root)
424
+ y_pad_top = 50 # space for title and info
425
+ y_pad_bottom = 40 # space for bottom text
426
+ svg_height = y_pad_top + (max_depth + 2) * frame_height + y_pad_bottom
427
+ x_pad = 10
428
+ chart_width = width - 2 * x_pad
429
+
430
+ # Flatten tree into rectangles
431
+ # Each rect: (x, y, w, h, name, self_count, total_count)
432
+ rects: List[Tuple[float, float, float, float, str, int, int]] = []
433
+
434
+ def _layout(node, x_start, depth):
435
+ total = _total_value(node)
436
+ w = (total / root_total) * chart_width
437
+
438
+ if w < min_width_px:
439
+ return
440
+
441
+ # y position: flame graph is bottom-up, so deeper = lower
442
+ # We'll use an inverted layout: root at bottom
443
+ y = svg_height - y_pad_bottom - (depth + 1) * frame_height
444
+
445
+ rects.append((
446
+ x_start + x_pad, y, w, frame_height - 1,
447
+ node["name"], node["value"], total,
448
+ ))
449
+
450
+ # Layout children left-to-right
451
+ child_x = x_start
452
+ for child in sorted(node["children"].values(),
453
+ key=lambda c: _total_value(c), reverse=True):
454
+ child_total = _total_value(child)
455
+ child_w = (child_total / root_total) * chart_width
456
+ if child_w >= min_width_px:
457
+ _layout(child, child_x, depth + 1)
458
+ child_x += child_w
459
+
460
+ # Layout from root's children (skip the "root" pseudo-node itself)
461
+ child_x = 0.0
462
+ for child in sorted(root["children"].values(),
463
+ key=lambda c: _total_value(c), reverse=True):
464
+ _layout(child, child_x, 0)
465
+ child_total = _total_value(child)
466
+ child_x += (child_total / root_total) * chart_width
467
+
468
+ # Generate SVG
469
+ svg_parts = []
470
+ svg_parts.append(
471
+ f'<svg xmlns="http://www.w3.org/2000/svg" '
472
+ f'viewBox="0 0 {width} {svg_height}" '
473
+ f'width="{width}" height="{svg_height}" '
474
+ f'style="background:#0d1117" '
475
+ f'data-chart-width="{chart_width}" data-x-pad="{x_pad}" '
476
+ f'data-total-samples="{total_samples}">'
477
+ )
478
+
479
+ # Embedded styles and interactivity
480
+ svg_parts.append("""
481
+ <defs>
482
+ <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
483
+ <stop offset="0%" style="stop-color:#161b22"/>
484
+ <stop offset="100%" style="stop-color:#0d1117"/>
485
+ </linearGradient>
486
+ <filter id="outline" x="-2%" y="-2%" width="104%" height="104%">
487
+ <feMorphology in="SourceAlpha" result="dilated" operator="dilate" radius="1"/>
488
+ <feFlood flood-color="#000000" flood-opacity="0.9" result="black"/>
489
+ <feComposite in="black" in2="dilated" operator="in" result="shadow"/>
490
+ <feMerge>
491
+ <feMergeNode in="shadow"/>
492
+ <feMergeNode in="SourceGraphic"/>
493
+ </feMerge>
494
+ </filter>
495
+ </defs>
496
+ <style>
497
+ .fg-frame { stroke: #0d1117; stroke-width: 0.5; cursor: pointer; }
498
+ .fg-frame:hover { stroke: #f0e68c; stroke-width: 1.5; }
499
+ .fg-label { font-family: 'SF Mono', Consolas, monospace; fill: #ffffff;
500
+ filter: url(#outline); pointer-events: none; }
501
+ .fg-title { font-family: -apple-system, sans-serif; fill: #e6edf3;
502
+ font-weight: 700; }
503
+ .fg-info { font-family: 'SF Mono', Consolas, monospace; fill: #8b949e; }
504
+ </style>
505
+ """)
506
+
507
+ # Background
508
+ svg_parts.append(
509
+ f'<rect x="0" y="0" width="{width}" height="{svg_height}" '
510
+ f'fill="url(#bg)" />'
511
+ )
512
+
513
+ # Title
514
+ svg_parts.append(
515
+ f'<text x="{width / 2}" y="24" text-anchor="middle" '
516
+ f'class="fg-title" font-size="16">{_svg_escape(title)}</text>'
517
+ )
518
+
519
+ # Info line
520
+ svg_parts.append(
521
+ f'<text x="{width / 2}" y="40" text-anchor="middle" '
522
+ f'class="fg-info" font-size="11">'
523
+ f'samples: {total_samples} | '
524
+ f'Hover for details, click to zoom</text>'
525
+ )
526
+
527
+ # Tooltip / details container
528
+ svg_parts.append(
529
+ f'<text class="fg-details" x="{x_pad}" y="{svg_height - 12}" '
530
+ f'font-size="11"> </text>'
531
+ )
532
+
533
+ # Render frames – store original geometry as data attributes so the
534
+ # HTML-side JS can implement zoom (scale / translate) without needing
535
+ # an embedded <script> (which does not execute when SVG is injected
536
+ # via innerHTML).
537
+ #
538
+ # Each frame's text is clipped via a <clipPath> that matches the
539
+ # rectangle bounds, so labels never overflow even if truncation
540
+ # estimates are slightly off. A trailing "…" is appended when the
541
+ # label is truncated to signal that the full name is available on
542
+ # hover / in the details bar.
543
+ char_w = font_size * 0.65 # approximate width of a single monospace char
544
+ min_text_w = 20 # show text in frames wider than this (px)
545
+
546
+ for i, (x, y, w, h, name, self_count, total_count) in enumerate(rects):
547
+ color = _flame_color(name, i)
548
+ pct = (total_count / total_samples) * 100
549
+
550
+ # Truncate label to fit – leave 8 px padding (4 left + 4 right)
551
+ avail_w = w - 8
552
+ max_chars = int(avail_w / char_w) if avail_w > 0 else 0
553
+
554
+ if max_chars >= len(name):
555
+ label = name # fits completely
556
+ elif max_chars > 3:
557
+ label = name[: max_chars - 1] + "\u2026" # truncate + ellipsis
558
+ elif max_chars > 0:
559
+ label = name[: max_chars] # very narrow – no room for ellipsis
560
+ else:
561
+ label = ""
562
+
563
+ clip_id = f"clip-{i}"
564
+
565
+ svg_parts.append(
566
+ f'<g class="fg-frame" '
567
+ f'data-name="{_svg_escape(name)}" '
568
+ f'data-samples="{total_count}" '
569
+ f'data-pct="{pct:.2f}" '
570
+ f'data-x="{x:.1f}" data-y="{y:.1f}" '
571
+ f'data-w="{w:.1f}" data-h="{h}">'
572
+ )
573
+ # clipPath keeps text inside the rectangle
574
+ svg_parts.append(
575
+ f'<clipPath id="{clip_id}">'
576
+ f'<rect x="{x:.1f}" y="{y:.1f}" '
577
+ f'width="{w:.1f}" height="{h}" />'
578
+ f'</clipPath>'
579
+ )
580
+ svg_parts.append(
581
+ f'<rect x="{x:.1f}" y="{y:.1f}" '
582
+ f'width="{w:.1f}" height="{h}" '
583
+ f'fill="{color}" rx="2" />'
584
+ )
585
+ if label and w > min_text_w:
586
+ svg_parts.append(
587
+ f'<text x="{x + 4:.1f}" y="{y + h - 3:.1f}" '
588
+ f'clip-path="url(#{clip_id})" '
589
+ f'class="fg-label" font-size="{font_size}">'
590
+ f'{_svg_escape(label)}</text>'
591
+ )
592
+ svg_parts.append('</g>')
593
+
594
+ svg_parts.append('</svg>')
595
+
596
+ return "\n".join(svg_parts)
597
+
598
+
599
+ def _svg_escape(text: str) -> str:
600
+ """Escape text for safe SVG/XML inclusion."""
601
+ return (text.replace("&", "&amp;")
602
+ .replace("<", "&lt;")
603
+ .replace(">", "&gt;")
604
+ .replace('"', "&quot;")
605
+ .replace("'", "&#39;"))
606
+
607
+
608
+ def _flame_color(name: str, index: int) -> str:
609
+ """Pick a warm flame color for a flame graph frame.
610
+
611
+ Uses a hash-based approach to generate classic flame graph colors
612
+ (red → orange → yellow gradient). The same function name always
613
+ gets the same color for easy cross-run comparison.
614
+ """
615
+ # Kernel frames / unknown addresses → muted grey
616
+ if name.startswith(("[", "0x")):
617
+ return "#626262"
618
+
619
+ # Hash the name for deterministic color assignment
620
+ h = hash(name) & 0xFFFFFFFF
621
+
622
+ # Classic flame graph palette:
623
+ # Red channel: 200–240 (warm base)
624
+ # Green channel: 50–200 (controls red→orange→yellow shift)
625
+ # Blue channel: 30–55 (subtle warmth)
626
+ r = 200 + (h % 41) # 200–240
627
+ g = 50 + ((h >> 8) % 151) # 50–200
628
+ b = 30 + ((h >> 16) % 26) # 30–55
629
+
630
+ return f"#{r:02x}{g:02x}{b:02x}"