benchmaker 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.
benchmaker/cli.py ADDED
@@ -0,0 +1,382 @@
1
+ """bench-maker CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import sys
9
+ from typing import Any
10
+
11
+ import click
12
+ import yaml
13
+
14
+ from benchmaker.config import build_config
15
+ from benchmaker.runner import BenchRunner
16
+
17
+
18
+ # ---------------------------------------------------------------- shared bits
19
+
20
+
21
+ def _output_options(f):
22
+ """Attach --out-dir / --run-id / --label / --notes to a command."""
23
+ f = click.option("--out-dir", type=click.Path(file_okay=False), default=None,
24
+ help="Parent directory for the run bundle. The bundle is written "
25
+ "to <out-dir>/<run-id>/.")(f)
26
+ f = click.option("--run-id", default=None,
27
+ help="Explicit run id. Defaults to a UTC timestamp.")(f)
28
+ f = click.option("--label", "labels", multiple=True,
29
+ help="Free-form 'key=value' tag stored in meta.json. Repeatable.")(f)
30
+ f = click.option("--notes", default="", help="Free-form notes stored in meta.json.")(f)
31
+ return f
32
+
33
+
34
+ def _parse_labels(items: tuple[str, ...]) -> dict[str, str]:
35
+ out: dict[str, str] = {}
36
+ for it in items:
37
+ if "=" not in it:
38
+ raise click.BadParameter(f"--label must be 'key=value', got {it!r}")
39
+ k, v = it.split("=", 1)
40
+ out[k.strip()] = v.strip()
41
+ return out
42
+
43
+
44
+ def _write_bundle_if_requested(runner: BenchRunner, source_config: dict,
45
+ out_dir: str | None, run_id: str | None,
46
+ labels: tuple[str, ...], notes: str) -> None:
47
+ if not out_dir:
48
+ return
49
+ path = runner.write_bundle(
50
+ out_dir,
51
+ run_id=run_id,
52
+ source_config=source_config,
53
+ labels=_parse_labels(labels),
54
+ notes=notes,
55
+ )
56
+ sys.stderr.write(f"[bench-maker] wrote bundle to {path}\n")
57
+
58
+
59
+ # ---------------------------------------------------------------- main
60
+
61
+
62
+ @click.group()
63
+ @click.option("--log-level", default="INFO",
64
+ type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"], case_sensitive=False),
65
+ help="Logging level (default: INFO).")
66
+ def main(log_level: str) -> None:
67
+ """bench-maker: async HTTP benchmarking with pluggable workloads."""
68
+ logging.basicConfig(
69
+ level=log_level.upper(),
70
+ format="%(asctime)s [%(name)s] %(message)s",
71
+ datefmt="%H:%M:%S",
72
+ )
73
+
74
+
75
+ @main.command()
76
+ @click.argument("config_path", type=click.Path(exists=True, dir_okay=False))
77
+ @_output_options
78
+ @click.option("--dotenv", type=click.Path(), default=".env",
79
+ help="Path to .env file to load (default: .env). "
80
+ "Use --dotenv '' to disable.")
81
+ @click.option("--record", "record_path", type=click.Path(), default=None,
82
+ help="Write a JSONL request trace (with relative timestamps) to "
83
+ "this path. A later run can replay it deterministically via "
84
+ "a 'replay:' config block. Overrides any 'record:' in YAML.")
85
+ @click.option("--replay", "replay_path", type=click.Path(exists=True, dir_okay=False),
86
+ default=None,
87
+ help="Replay a previously recorded trace at the same relative "
88
+ "timings. Overrides 'workload_type' / 'workload' / 'load' "
89
+ "(and any 'replay:' in YAML).")
90
+ @click.option("--replay-speed", type=float, default=None,
91
+ help="Speed multiplier for --replay (default 1.0).")
92
+ @click.option("--quiet", is_flag=True, help="Suppress progress output.")
93
+ def run(config_path: str, out_dir: str | None, run_id: str | None,
94
+ labels: tuple[str, ...], notes: str, dotenv: str,
95
+ record_path: str | None, replay_path: str | None,
96
+ replay_speed: float | None, quiet: bool) -> None:
97
+ """Run a benchmark from a YAML config file.
98
+
99
+ Environment variables (loaded from `.env` by default) are interpolated
100
+ into the YAML using `${VAR}` or `${VAR:-default}` syntax.
101
+ """
102
+ with open(config_path) as f:
103
+ raw_cfg = yaml.safe_load(f)
104
+
105
+ if record_path is not None:
106
+ raw_cfg = {**raw_cfg, "record": {"path": record_path}}
107
+ if replay_path is not None:
108
+ replay_cfg: dict = {"path": replay_path}
109
+ if replay_speed is not None:
110
+ replay_cfg["speed"] = replay_speed
111
+ raw_cfg = {**raw_cfg, "replay": replay_cfg}
112
+
113
+ bench_cfg = build_config(raw_cfg, dotenv_path=(dotenv or None))
114
+ if quiet:
115
+ bench_cfg.progress_every_s = 0.0
116
+
117
+ runner = BenchRunner(bench_cfg)
118
+ asyncio.run(runner.run())
119
+ runner.metrics.render(sys.stdout)
120
+ _write_bundle_if_requested(runner, raw_cfg, out_dir, run_id, labels, notes)
121
+
122
+
123
+ @main.command()
124
+ @click.option("--url", required=True, help="Target URL.")
125
+ @click.option("--method", default="GET")
126
+ @click.option("--header", "-H", multiple=True, help="Header 'Name: value'.")
127
+ @click.option("--json-body", default=None, help="JSON body string.")
128
+ @click.option("--data", default=None, help="Raw body string.")
129
+ @click.option("--rate", default="10", help="Load spec, e.g. '100', 'poisson:100', "
130
+ "'closed:32', 'ramp:10..500:30s'.")
131
+ @click.option("--duration", default="10s", help="Run duration (e.g. '30s', '2m').")
132
+ @click.option("--max-requests", type=int, default=None)
133
+ @click.option("--timeout", "timeout_s", default=60.0, type=float)
134
+ @click.option("--connection-limit", default=1000, type=int)
135
+ @_output_options
136
+ @click.option("--quiet", is_flag=True)
137
+ def quick(url: str, method: str, header: tuple[str, ...], json_body: str | None,
138
+ data: str | None, rate: str, duration: str, max_requests: int | None,
139
+ timeout_s: float, connection_limit: int,
140
+ out_dir: str | None, run_id: str | None,
141
+ labels: tuple[str, ...], notes: str, quiet: bool) -> None:
142
+ """One-liner benchmark of a single endpoint (no config file)."""
143
+ cfg: dict = {
144
+ "workload_type": {
145
+ "type": "http",
146
+ "url": url,
147
+ "method": method,
148
+ "headers": _parse_headers(header),
149
+ "timeout_s": timeout_s,
150
+ },
151
+ "load": rate,
152
+ "duration": duration,
153
+ "max_requests": max_requests,
154
+ "timeout_s": timeout_s,
155
+ "connection_limit": connection_limit,
156
+ }
157
+ if json_body is not None:
158
+ cfg["workload"] = {"type": "static", "items": [json.loads(json_body)]}
159
+ elif data is not None:
160
+ cfg["workload"] = {"type": "static", "items": [data.encode("utf-8")]}
161
+
162
+ bench_cfg = build_config(cfg)
163
+ if quiet:
164
+ bench_cfg.progress_every_s = 0.0
165
+
166
+ runner = BenchRunner(bench_cfg)
167
+ asyncio.run(runner.run())
168
+ runner.metrics.render(sys.stdout)
169
+ _write_bundle_if_requested(runner, cfg, out_dir, run_id, labels, notes)
170
+
171
+
172
+ @main.command()
173
+ @click.option("--url", default=None,
174
+ help="Endpoint URL (e.g. http://host:8000/v1/chat/completions). "
175
+ "Falls back to $OPENAI_API_BASE_URL/$OPENAI_BASE_URL.")
176
+ @click.option("--model", default=None,
177
+ help="Model name. Falls back to $OPENAI_COMPATIBLE_MODEL/$OPENAI_MODEL.")
178
+ @click.option("--api-key", default=None,
179
+ help="API key. Falls back to $OPENAI_API_KEY.")
180
+ @click.option("--header", "-H", multiple=True, help="Extra header 'Name: value'.")
181
+ @click.option("--prompt", "prompts", multiple=True,
182
+ help="Prompt text (repeatable). Mutually exclusive with --prompts-jsonl.")
183
+ @click.option("--prompts-jsonl", type=click.Path(exists=True, dir_okay=False), default=None,
184
+ help="JSONL file of prompts.")
185
+ @click.option("--prompt-field", default="prompt",
186
+ help="Field to extract from each JSONL row (default: 'prompt').")
187
+ @click.option("--shuffle/--no-shuffle", default=True, help="Shuffle prompts (static only).")
188
+ @click.option("--seed", type=int, default=0)
189
+ @click.option("--max-tokens", type=int, default=128)
190
+ @click.option("--min-tokens", type=int, default=None,
191
+ help="vLLM/SGLang extension: minimum tokens before EOS is honored.")
192
+ @click.option("--ignore-eos/--no-ignore-eos", default=None,
193
+ help="vLLM/SGLang extension: keep generating past EOS until max_tokens.")
194
+ @click.option("--temperature", type=float, default=0.0)
195
+ @click.option("--top-p", type=float, default=None)
196
+ @click.option("--top-k", type=int, default=None)
197
+ @click.option("--stop", multiple=True, help="Stop string (repeatable).")
198
+ @click.option("--extra", "extras", multiple=True,
199
+ help="Extra sampling param 'key=value' (value parsed as JSON, else string). "
200
+ "Repeatable.")
201
+ @click.option("--rate", default="10",
202
+ help="Load spec, e.g. '100', 'poisson:100', 'closed:32', 'ramp:10..500:30s'.")
203
+ @click.option("--duration", default="10s")
204
+ @click.option("--max-requests", type=int, default=None)
205
+ @click.option("--timeout", "timeout_s", default=600.0, type=float)
206
+ @click.option("--connection-limit", default=1000, type=int)
207
+ @click.option("--dotenv", type=click.Path(), default=".env",
208
+ help="Path to .env file (default: .env). Use --dotenv '' to disable.")
209
+ @_output_options
210
+ @click.option("--quiet", is_flag=True)
211
+ def llm(url: str | None, model: str | None, api_key: str | None,
212
+ header: tuple[str, ...],
213
+ prompts: tuple[str, ...], prompts_jsonl: str | None, prompt_field: str,
214
+ shuffle: bool, seed: int,
215
+ max_tokens: int, min_tokens: int | None, ignore_eos: bool | None,
216
+ temperature: float, top_p: float | None, top_k: int | None,
217
+ stop: tuple[str, ...], extras: tuple[str, ...],
218
+ rate: str, duration: str, max_requests: int | None,
219
+ timeout_s: float, connection_limit: int,
220
+ dotenv: str,
221
+ out_dir: str | None, run_id: str | None,
222
+ labels: tuple[str, ...], notes: str,
223
+ quiet: bool) -> None:
224
+ """Benchmark an OpenAI-compatible chat-completions endpoint."""
225
+ if prompts and prompts_jsonl:
226
+ raise click.UsageError("--prompt and --prompts-jsonl are mutually exclusive.")
227
+ if not prompts and not prompts_jsonl:
228
+ raise click.UsageError("Provide at least one --prompt or --prompts-jsonl.")
229
+
230
+ from benchmaker.config import build_workload
231
+ from benchmaker.load import parse_duration, parse_rate_spec
232
+ from benchmaker.runner import BenchConfig
233
+ from benchmaker.workloads.llm import OpenAIChatWorkloadType
234
+
235
+ wt_kwargs: dict[str, Any] = {
236
+ "max_tokens": max_tokens,
237
+ "temperature": temperature,
238
+ "timeout_s": timeout_s,
239
+ "headers": _parse_headers(header),
240
+ }
241
+ if min_tokens is not None:
242
+ wt_kwargs["min_tokens"] = min_tokens
243
+ if ignore_eos is not None:
244
+ wt_kwargs["ignore_eos"] = ignore_eos
245
+ if top_p is not None:
246
+ wt_kwargs["top_p"] = top_p
247
+ if top_k is not None:
248
+ wt_kwargs["top_k"] = top_k
249
+ if stop:
250
+ wt_kwargs["stop"] = list(stop)
251
+ for item in extras:
252
+ if "=" not in item:
253
+ raise click.BadParameter(f"--extra must be 'key=value', got {item!r}")
254
+ k, v = item.split("=", 1)
255
+ try:
256
+ parsed: Any = json.loads(v)
257
+ except json.JSONDecodeError:
258
+ parsed = v
259
+ wt_kwargs[k.strip()] = parsed
260
+
261
+ wt = OpenAIChatWorkloadType.from_env(
262
+ url=url, model=model, api_key=api_key,
263
+ dotenv_path=(dotenv or None),
264
+ **wt_kwargs,
265
+ )
266
+
267
+ if prompts:
268
+ workload_spec: Any = {
269
+ "type": "static",
270
+ "items": list(prompts),
271
+ "shuffle": shuffle,
272
+ "seed": seed,
273
+ }
274
+ else:
275
+ workload_spec = {
276
+ "type": "jsonl",
277
+ "path": prompts_jsonl,
278
+ "field": prompt_field,
279
+ }
280
+ workload = build_workload(workload_spec)
281
+
282
+ dur = parse_duration(duration)
283
+ load_model = parse_rate_spec(rate, duration_s=dur, max_requests=max_requests)
284
+ bench_cfg = BenchConfig(
285
+ workload_type=wt,
286
+ workload=workload,
287
+ load=load_model,
288
+ timeout_s=timeout_s,
289
+ connection_limit=connection_limit,
290
+ )
291
+
292
+ if quiet:
293
+ bench_cfg.progress_every_s = 0.0
294
+
295
+ runner = BenchRunner(bench_cfg)
296
+ asyncio.run(runner.run())
297
+ runner.metrics.render(sys.stdout)
298
+
299
+ source_config = {
300
+ "workload_type": {
301
+ "type": "openai-chat", "url": wt._url, "model": wt._model,
302
+ **{k: v for k, v in wt_kwargs.items() if k != "headers"},
303
+ },
304
+ "workload": workload_spec,
305
+ "load": rate,
306
+ "duration": duration,
307
+ "max_requests": max_requests,
308
+ "timeout_s": timeout_s,
309
+ "connection_limit": connection_limit,
310
+ }
311
+ _write_bundle_if_requested(runner, source_config, out_dir, run_id, labels, notes)
312
+
313
+
314
+ # ---------------------------------------------------------------- collect
315
+
316
+
317
+ @main.command()
318
+ @click.argument("paths", nargs=-1, required=True,
319
+ type=click.Path(exists=True, file_okay=False))
320
+ @click.option("--format", "fmt", type=click.Choice(["md", "csv", "json"]),
321
+ default="md", show_default=True,
322
+ help="Output format. 'md' is a Markdown table, 'csv' is comma-separated, "
323
+ "'json' is a JSON array of row dicts.")
324
+ @click.option("--metric", "metrics", multiple=True,
325
+ help="Extra dotted-path metric to add as a column "
326
+ "(e.g. 'workload_metrics.ttft_s.p50'). Repeatable.")
327
+ @click.option("--columns", default=None,
328
+ help="Comma-separated list of column names to keep (after metrics are added). "
329
+ "Overrides the default column set.")
330
+ @click.option("--sort-by", default=None,
331
+ help="Column name to sort rows by (ascending).")
332
+ @click.option("--label", "label_keys", multiple=True,
333
+ help="Promote a meta.labels[<key>] entry into its own column. Repeatable.")
334
+ @click.option("--recursive/--no-recursive", default=True,
335
+ help="When a path is a directory of run-dirs, descend one level to find them.")
336
+ def collect(paths: tuple[str, ...], fmt: str, metrics: tuple[str, ...],
337
+ columns: str | None, sort_by: str | None,
338
+ label_keys: tuple[str, ...], recursive: bool) -> None:
339
+ """Collect summaries from one or more run-dirs into a table.
340
+
341
+ Each PATH may be a run directory (containing meta.json + summary.json) or a
342
+ directory of such run-dirs. With --recursive (default), a non-bundle
343
+ directory is scanned for immediate subdirectories that are bundles.
344
+ """
345
+ from benchmaker.collect import collect_table, format_table, find_bundles
346
+
347
+ bundle_dirs: list[str] = []
348
+ for p in paths:
349
+ bundle_dirs.extend(find_bundles(p, recursive=recursive))
350
+ if not bundle_dirs:
351
+ raise click.UsageError(
352
+ f"No run bundles found under: {', '.join(paths)}. "
353
+ "Run bundles must contain meta.json and summary.json."
354
+ )
355
+
356
+ rows, column_names = collect_table(
357
+ bundle_dirs,
358
+ extra_metrics=list(metrics),
359
+ label_keys=list(label_keys),
360
+ )
361
+ if columns:
362
+ column_names = [c.strip() for c in columns.split(",") if c.strip()]
363
+ if sort_by:
364
+ rows.sort(key=lambda r: (r.get(sort_by) is None, r.get(sort_by)))
365
+
366
+ sys.stdout.write(format_table(rows, column_names, fmt))
367
+ if fmt != "json":
368
+ sys.stdout.write("\n")
369
+
370
+
371
+ def _parse_headers(items: tuple[str, ...]) -> dict[str, str]:
372
+ out: dict[str, str] = {}
373
+ for it in items:
374
+ if ":" not in it:
375
+ raise click.BadParameter(f"Header must be 'Name: value', got {it!r}")
376
+ k, v = it.split(":", 1)
377
+ out[k.strip()] = v.strip()
378
+ return out
379
+
380
+
381
+ if __name__ == "__main__":
382
+ main()
benchmaker/collect.py ADDED
@@ -0,0 +1,178 @@
1
+ """Collect run bundles into a comparison table.
2
+
3
+ `collect_table(bundle_dirs, ...)` reads every bundle's `meta.json` +
4
+ `summary.json` and produces a list of row dicts plus a column ordering.
5
+ `format_table(rows, columns, fmt)` renders them as Markdown, CSV, or JSON.
6
+
7
+ The default columns capture the core throughput/latency/error metrics. Users
8
+ can ask for additional dotted-path metrics via `extra_metrics=` (e.g.
9
+ `workload_metrics.ttft_s.p50`).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import csv
15
+ import io
16
+ import json
17
+ import os
18
+ from typing import Any, Iterable, Optional
19
+
20
+ from benchmaker.bundle import is_bundle_dir, read_bundle
21
+
22
+
23
+ # Columns surfaced by default. (Order matters.)
24
+ DEFAULT_COLUMNS: list[tuple[str, str]] = [
25
+ ("run_id", "meta.run_id"),
26
+ ("workload_type", "meta.workload_type"),
27
+ ("workload", "meta.workload"),
28
+ ("wall_s", "meta.wall_time_s"),
29
+ ("total", "summary.total_requests"),
30
+ ("ok", "summary.success"),
31
+ ("fail", "summary.failed"),
32
+ ("err_rate", "summary.error_rate"),
33
+ ("rps", "summary.throughput_rps"),
34
+ ("good_rps", "summary.goodput_rps"),
35
+ ("p50_s", "summary.latency_s.p50"),
36
+ ("p90_s", "summary.latency_s.p90"),
37
+ ("p99_s", "summary.latency_s.p99"),
38
+ ("max_s", "summary.latency_s.max"),
39
+ ]
40
+
41
+
42
+ def find_bundles(path: str, *, recursive: bool = True) -> list[str]:
43
+ """Return run-bundle directories under `path`.
44
+
45
+ If `path` is itself a bundle, return `[path]`. Otherwise list its immediate
46
+ subdirectories and keep the ones that look like bundles. With
47
+ `recursive=False`, only the top-level is checked.
48
+ """
49
+ if is_bundle_dir(path):
50
+ return [path]
51
+ if not recursive or not os.path.isdir(path):
52
+ return []
53
+ out: list[str] = []
54
+ for name in sorted(os.listdir(path)):
55
+ child = os.path.join(path, name)
56
+ if is_bundle_dir(child):
57
+ out.append(child)
58
+ return out
59
+
60
+
61
+ def _dotted_get(d: dict, path: str, default: Any = None) -> Any:
62
+ """`a.b.c` lookup; segments may be dict keys (including ones with dots, tried first)."""
63
+ if path in d:
64
+ return d[path]
65
+ cur: Any = d
66
+ for part in path.split("."):
67
+ if isinstance(cur, dict) and part in cur:
68
+ cur = cur[part]
69
+ else:
70
+ return default
71
+ return cur
72
+
73
+
74
+ def collect_table(
75
+ bundle_dirs: Iterable[str],
76
+ *,
77
+ extra_metrics: Optional[list[str]] = None,
78
+ label_keys: Optional[list[str]] = None,
79
+ ) -> tuple[list[dict[str, Any]], list[str]]:
80
+ """Read each bundle and return (rows, column_names).
81
+
82
+ Each row is keyed by friendly column names (e.g. `rps`, `p50_s`). Extra
83
+ metrics keep their full dotted path as the column name, so a caller can
84
+ re-pick columns via the `columns` arg.
85
+ """
86
+ extra_metrics = extra_metrics or []
87
+ label_keys = label_keys or []
88
+
89
+ columns: list[str] = [c for c, _ in DEFAULT_COLUMNS]
90
+ for lk in label_keys:
91
+ columns.append(f"label.{lk}")
92
+ columns.extend(extra_metrics)
93
+
94
+ rows: list[dict[str, Any]] = []
95
+ for d in bundle_dirs:
96
+ bundle = read_bundle(d)
97
+ meta = bundle["meta"]
98
+ summary = bundle["summary"]
99
+ ctx = {"meta": meta, "summary": summary}
100
+
101
+ row: dict[str, Any] = {}
102
+ for col, path in DEFAULT_COLUMNS:
103
+ row[col] = _dotted_get(ctx, path)
104
+ for lk in label_keys:
105
+ row[f"label.{lk}"] = (meta.get("labels") or {}).get(lk)
106
+ for m in extra_metrics:
107
+ # Allow the user to write `summary.foo` or just `foo` (assumes summary.*).
108
+ if m.startswith("meta.") or m.startswith("summary."):
109
+ row[m] = _dotted_get(ctx, m)
110
+ else:
111
+ row[m] = _dotted_get(summary, m)
112
+ rows.append(row)
113
+
114
+ return rows, columns
115
+
116
+
117
+ # ----------------------------------------------------------------- formatters
118
+
119
+
120
+ def format_table(rows: list[dict[str, Any]], columns: list[str], fmt: str) -> str:
121
+ fmt = fmt.lower()
122
+ if fmt == "md":
123
+ return _format_md(rows, columns)
124
+ if fmt == "csv":
125
+ return _format_csv(rows, columns)
126
+ if fmt == "json":
127
+ return json.dumps(
128
+ [{c: r.get(c) for c in columns} for r in rows],
129
+ indent=2,
130
+ default=str,
131
+ )
132
+ raise ValueError(f"Unknown format {fmt!r}")
133
+
134
+
135
+ def _format_csv(rows: list[dict[str, Any]], columns: list[str]) -> str:
136
+ buf = io.StringIO()
137
+ w = csv.writer(buf)
138
+ w.writerow(columns)
139
+ for r in rows:
140
+ w.writerow([_csv_cell(r.get(c)) for c in columns])
141
+ return buf.getvalue().rstrip("\n")
142
+
143
+
144
+ def _csv_cell(v: Any) -> Any:
145
+ if v is None:
146
+ return ""
147
+ if isinstance(v, float):
148
+ return f"{v:.6g}"
149
+ return v
150
+
151
+
152
+ def _format_md(rows: list[dict[str, Any]], columns: list[str]) -> str:
153
+ rendered: list[list[str]] = [[_md_cell(r.get(c)) for c in columns] for r in rows]
154
+ widths = [len(c) for c in columns]
155
+ for r in rendered:
156
+ for i, cell in enumerate(r):
157
+ widths[i] = max(widths[i], len(cell))
158
+
159
+ def line(cells: list[str]) -> str:
160
+ return "| " + " | ".join(cells[i].ljust(widths[i]) for i in range(len(cells))) + " |"
161
+
162
+ header = line(columns)
163
+ sep = "|" + "|".join("-" * (widths[i] + 2) for i in range(len(columns))) + "|"
164
+ body = [line(r) for r in rendered]
165
+ return "\n".join([header, sep, *body])
166
+
167
+
168
+ def _md_cell(v: Any) -> str:
169
+ if v is None:
170
+ return ""
171
+ if isinstance(v, float):
172
+ # Compact, but keep small-magnitude numbers readable.
173
+ if abs(v) >= 1000:
174
+ return f"{v:.1f}"
175
+ if abs(v) >= 1:
176
+ return f"{v:.3f}"
177
+ return f"{v:.4g}"
178
+ return str(v)