|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import shutil |
| 4 | +from collections.abc import Sequence |
| 5 | + |
| 6 | +_BLOCKS = ' ▁▂▃▄▅▆▇█' |
| 7 | + |
| 8 | + |
| 9 | +def _format_duration(t: float) -> str: |
| 10 | + """Format *t* seconds into a compact human-readable string.""" |
| 11 | + if t >= 1.0: |
| 12 | + return f'{t:.3f}s' |
| 13 | + if t >= 1e-3: |
| 14 | + return f'{t * 1e3:.2f}ms' |
| 15 | + if t >= 1e-6: |
| 16 | + return f'{t * 1e6:.2f}\u03bcs' |
| 17 | + return f'{t * 1e9:.2f}ns' |
| 18 | + |
| 19 | + |
| 20 | +def terminal_width() -> int: |
| 21 | + """Return the current terminal width, clamped to a minimum of 20.""" |
| 22 | + cols = shutil.get_terminal_size((80, 24)).columns |
| 23 | + return max(cols, 20) |
| 24 | + |
| 25 | + |
| 26 | +def draw_box(lines: list[str], width: int) -> list[str]: |
| 27 | + """Draw a Unicode border box around *lines*. |
| 28 | +
|
| 29 | + Each output line is exactly *width* characters wide. Content lines are |
| 30 | + padded with spaces or truncated to ``width - 4`` characters (2 chars for |
| 31 | + the border + space on each side). |
| 32 | +
|
| 33 | + Box drawing uses ASCII + box-drawing characters (``╭╮╰╯│─``). Width is |
| 34 | + measured by ``len()``, which counts every character as 1 column. Emoji or |
| 35 | + CJK characters may cause visual misalignment. |
| 36 | +
|
| 37 | + Args: |
| 38 | + lines: Content lines to render inside the box. |
| 39 | + width: Total width of each output line, including borders. |
| 40 | + """ |
| 41 | + inner_width = width - 4 # 2 for │ and 1 space on each side |
| 42 | + top = '╭' + '─' * (width - 2) + '╮' |
| 43 | + bottom = '╰' + '─' * (width - 2) + '╯' |
| 44 | + result = [top] |
| 45 | + for line in lines: |
| 46 | + if len(line) > inner_width: |
| 47 | + content = line[:inner_width] |
| 48 | + else: |
| 49 | + content = line.ljust(inner_width) |
| 50 | + result.append('│ ' + content + ' │') |
| 51 | + result.append(bottom) |
| 52 | + return result |
| 53 | + |
| 54 | + |
| 55 | +def draw_nested( |
| 56 | + inner_blocks: list[list[str]], |
| 57 | + extras: list[str], |
| 58 | + width: int, |
| 59 | +) -> list[str]: |
| 60 | + """Draw an outer border box wrapping pre-rendered *inner_blocks*. |
| 61 | +
|
| 62 | + Each inner block is already a list of strings (e.g. from :func:`draw_box`). |
| 63 | + They are placed inside the outer border with 1-space padding on each side. |
| 64 | + *extras* are additional plain-text lines rendered after the inner blocks, |
| 65 | + also padded. |
| 66 | +
|
| 67 | + Args: |
| 68 | + inner_blocks: List of already-rendered blocks (each a list of strings). |
| 69 | + extras: Extra plain-text lines appended after the inner blocks. |
| 70 | + width: Total width of the outer box. |
| 71 | + """ |
| 72 | + inner_width = width - 4 # space for │ + space on each side |
| 73 | + |
| 74 | + def pad_inner_line(line: str) -> str: |
| 75 | + if len(line) > inner_width: |
| 76 | + content = line[:inner_width] |
| 77 | + else: |
| 78 | + content = line.ljust(inner_width) |
| 79 | + return '│ ' + content + ' │' |
| 80 | + |
| 81 | + top = '╭' + '─' * (width - 2) + '╮' |
| 82 | + bottom = '╰' + '─' * (width - 2) + '╯' |
| 83 | + result = [top] |
| 84 | + for block in inner_blocks: |
| 85 | + for line in block: |
| 86 | + result.append(pad_inner_line(line)) |
| 87 | + for line in extras: |
| 88 | + result.append(pad_inner_line(line)) |
| 89 | + result.append(bottom) |
| 90 | + return result |
| 91 | + |
| 92 | + |
| 93 | +def histogram_bounds(durations: Sequence[float]) -> tuple[float, float]: |
| 94 | + """Return ``(lo, hi)`` bounds for a histogram: minimum and p99 value. |
| 95 | +
|
| 96 | + The p99 clip prevents extreme outliers from compressing the bulk of the |
| 97 | + distribution into the leftmost column. |
| 98 | +
|
| 99 | + Args: |
| 100 | + durations: Sequence of per-call timings in seconds. |
| 101 | +
|
| 102 | + Raises: |
| 103 | + ValueError: If *durations* is empty. |
| 104 | + """ |
| 105 | + if not durations: |
| 106 | + raise ValueError('durations must not be empty') |
| 107 | + sorted_durs = sorted(durations) |
| 108 | + lo = sorted_durs[0] |
| 109 | + p99_idx = min(len(sorted_durs) - 1, int(len(sorted_durs) * 0.99)) |
| 110 | + return lo, sorted_durs[p99_idx] |
| 111 | + |
| 112 | + |
| 113 | +def draw_histogram_axis(lo: float, hi: float, width: int) -> str: |
| 114 | + """Return a single-line x-axis label for a histogram with bounds *lo*/*hi*. |
| 115 | +
|
| 116 | + The minimum value is left-aligned; the p99 clip value is right-aligned. |
| 117 | + Both are formatted with auto-selected time units (ns / μs / ms / s). |
| 118 | +
|
| 119 | + Args: |
| 120 | + lo: Minimum value displayed on the x-axis. |
| 121 | + hi: Maximum value (p99 clip point) displayed on the x-axis. |
| 122 | + width: Total width of the label in characters. |
| 123 | + """ |
| 124 | + if width < 1: |
| 125 | + return '' |
| 126 | + left = _format_duration(lo) |
| 127 | + right = _format_duration(hi) + ' (p99)' |
| 128 | + if len(left) + 1 + len(right) > width: |
| 129 | + return left[:width] |
| 130 | + spaces = width - len(left) - len(right) |
| 131 | + return left + ' ' * spaces + right |
| 132 | + |
| 133 | + |
| 134 | +def draw_histogram(durations: Sequence[float], width: int, height: int) -> list[str]: |
| 135 | + """Render an ASCII bar chart of *durations* as a ``height`` x ``width`` grid. |
| 136 | +
|
| 137 | + The output is ``width`` characters wide and ``height`` rows tall. Each |
| 138 | + cell uses one of the Unicode block characters ``' ▁▂▃▄▅▆▇█'`` so bar |
| 139 | + heights are resolved at 1/8-row precision, giving smooth transitions |
| 140 | + between adjacent buckets. Returns an empty list when any dimension is |
| 141 | + zero/negative or when *durations* is empty. |
| 142 | +
|
| 143 | + Internally the data is bucketed into at most 20 bins regardless of |
| 144 | + *width*. Each bin is then rendered as ``width // n_buckets`` characters |
| 145 | + wide. This prevents timer-quantisation artefacts from producing a single |
| 146 | + spike with stray isolated pixels. |
| 147 | +
|
| 148 | + The x-axis upper bound is clipped to the p99 value via |
| 149 | + :func:`histogram_bounds`. Values above the clip point are omitted. |
| 150 | +
|
| 151 | + When all values are identical (``hi == lo``), the middle bin is drawn |
| 152 | + full-height and all others are left empty. |
| 153 | +
|
| 154 | + Args: |
| 155 | + durations: Sequence of per-call timings in seconds. |
| 156 | + width: Total number of output characters per row. |
| 157 | + height: Number of rows in the chart. |
| 158 | + """ |
| 159 | + if width < 1 or height < 1 or len(durations) == 0: |
| 160 | + return [] |
| 161 | + |
| 162 | + lo, hi = histogram_bounds(durations) |
| 163 | + |
| 164 | + # Cap the number of data buckets at 20 so that adjacent quantised timer |
| 165 | + # values are merged into the same wider bar. |
| 166 | + n_buckets = min(width, 20) |
| 167 | + counts = [0] * n_buckets |
| 168 | + |
| 169 | + if hi == lo: |
| 170 | + counts[n_buckets // 2] = 1 |
| 171 | + else: |
| 172 | + span = hi - lo |
| 173 | + for d in durations: |
| 174 | + if d > hi: |
| 175 | + continue |
| 176 | + idx = min(n_buckets - 1, int((d - lo) / span * n_buckets)) |
| 177 | + counts[idx] += 1 |
| 178 | + |
| 179 | + max_count = max(counts) |
| 180 | + bar_heights_float = [ |
| 181 | + c / max_count * height if c > 0 else 0.0 |
| 182 | + for c in counts |
| 183 | + ] |
| 184 | + |
| 185 | + rows: list[str] = [] |
| 186 | + for row in range(height - 1, -1, -1): |
| 187 | + line_chars: list[str] = [] |
| 188 | + for col in range(width): |
| 189 | + bh = bar_heights_float[col * n_buckets // width] |
| 190 | + if bh >= row + 1: |
| 191 | + line_chars.append('█') |
| 192 | + elif bh > row: |
| 193 | + frac = bh - row |
| 194 | + line_chars.append(_BLOCKS[max(1, min(8, round(frac * 8)))]) |
| 195 | + else: |
| 196 | + line_chars.append(' ') |
| 197 | + rows.append(''.join(line_chars)) |
| 198 | + return rows |
0 commit comments