darkrange-eval 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.
dreval/app.py ADDED
@@ -0,0 +1,458 @@
1
+ """Interactive TUI app for darkrange-eval.
2
+
3
+ Every screen carries the company logo + DARKRANGE wordmark. A scan shows a live
4
+ per-criterion spinner + score, then a prominent FINAL SCORE and the full detailed
5
+ report inline. Reports/results let you browse passed AND failed test cases.
6
+ """
7
+ import os
8
+ import sys
9
+ import textwrap
10
+ import threading
11
+ import time
12
+ import urllib.request
13
+
14
+ from . import banner, history, threshold, tui
15
+ from .criteria import CRITERIA, GATES, POOLS
16
+ from .runner import run_suite
17
+ from .task import load_suite, smoke_subset
18
+
19
+ CLAUDE_MODELS = ["claude-sonnet-5", "claude-opus-4-8", "claude-haiku-4-5-20251001"]
20
+ _CRIT_NAME = {c["key"]: c["name"] for c in CRITERIA}
21
+ _ASCII = bool(os.environ.get("DR_EVAL_ASCII"))
22
+ _SPIN = "|/-\\" if _ASCII else "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
23
+
24
+
25
+ def _suite_default():
26
+ return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
27
+ "suites", "darkrange")
28
+
29
+
30
+ def _detect_provider(url):
31
+ return "anthropic" if "anthropic" in (url or "").lower() else "openai"
32
+
33
+
34
+ def _fetch_models(endpoint, api_key=None):
35
+ url = endpoint.rstrip("/") + "/models"
36
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
37
+ try:
38
+ with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=6) as r:
39
+ import json
40
+ return [m.get("id") for m in json.load(r).get("data", []) if m.get("id")]
41
+ except Exception:
42
+ return []
43
+
44
+
45
+ def _wrap(text, maxchars=900, indent=" "):
46
+ text = "" if text is None else str(text)
47
+ if len(text) > maxchars:
48
+ text = text[:maxchars] + " ...(truncated)"
49
+ out = []
50
+ for para in text.split("\n"):
51
+ out.extend(textwrap.wrap(para, 86) or [""])
52
+ return "\n".join(indent + ln for ln in out) or (indent + "(empty)")
53
+
54
+
55
+ def _bar(x):
56
+ x = max(0.0, min(1.0, x))
57
+ f = int(round(x * 12))
58
+ return ("#" * f + "-" * (12 - f)) if _ASCII else ("█" * f + "░" * (12 - f))
59
+
60
+
61
+ def _mk(passed, errored=False):
62
+ if errored:
63
+ return "ERR"
64
+ return ("OK" if passed else "XX") if _ASCII else ("✔" if passed else "✗")
65
+
66
+
67
+ def _pulse(msg, cycles=8):
68
+ """A brief loading animation (only in a real terminal)."""
69
+ if not sys.stdout.isatty():
70
+ return
71
+ for i in range(cycles):
72
+ sys.stdout.write(f"\r {_SPIN[i % len(_SPIN)]} {msg}{'.' * (i % 4)} ")
73
+ sys.stdout.flush()
74
+ time.sleep(0.11)
75
+ sys.stdout.write("\r" + " " * (len(msg) + 12) + "\r")
76
+ sys.stdout.flush()
77
+
78
+
79
+ # ------------------------------------------------------------------ live progress
80
+ class _Progress:
81
+ """on_item callback + a background thread that keeps the current criterion's
82
+ spinner animating even while the model is thinking (continuous 'running' feel)."""
83
+
84
+ def __init__(self, tasks):
85
+ self.totals, self.order = {}, []
86
+ for t in tasks:
87
+ if t.criterion not in self.totals:
88
+ self.order.append(t.criterion)
89
+ self.totals[t.criterion] = 0
90
+ self.totals[t.criterion] += 1
91
+ self.cur, self.scores, self.k, self.tick = None, [], 0, 0
92
+ self.lock = threading.Lock()
93
+ self._stop = False
94
+ self.th = threading.Thread(target=self._animate, daemon=True)
95
+ self.th.start()
96
+
97
+ def _animate(self):
98
+ while not self._stop:
99
+ with self.lock:
100
+ if self.cur is not None:
101
+ self._draw()
102
+ time.sleep(0.1)
103
+
104
+ def _draw(self):
105
+ self.tick += 1
106
+ nm = _CRIT_NAME.get(self.cur, self.cur)[:30]
107
+ dots = "." * (self.tick % 4)
108
+ sys.stdout.write(f"\r {_SPIN[self.tick % len(_SPIN)]} {nm:32} "
109
+ f"[{self.k}/{self.totals.get(self.cur, '?')}] running{dots} ")
110
+ sys.stdout.flush()
111
+
112
+ def __call__(self, i, n, task, res):
113
+ with self.lock:
114
+ c = task.criterion
115
+ if c != self.cur:
116
+ self._finish_locked()
117
+ self.cur, self.scores, self.k = c, [], 0
118
+ self.k += 1
119
+ if res is not None and "endpoint_error" not in (res.flags or []) and res.score is not None:
120
+ self.scores.append(res.score)
121
+
122
+ def _finish_locked(self):
123
+ if self.cur is None:
124
+ return
125
+ vals = [s for s in self.scores if s is not None]
126
+ sc = sum(vals) / len(vals) if vals else 0.0
127
+ nm = _CRIT_NAME.get(self.cur, self.cur)[:30]
128
+ body = f" {_mk(sc >= 0.6)} {nm:32} {_bar(sc)} {sc:.2f} "
129
+ sys.stdout.write("\r" + (tui.ok(body) if sc >= 0.6 else tui.bad(body)) + "\n")
130
+ sys.stdout.flush()
131
+
132
+ def finish(self):
133
+ self._stop = True
134
+ try:
135
+ self.th.join(timeout=0.6)
136
+ except Exception:
137
+ pass
138
+ with self.lock:
139
+ self._finish_locked()
140
+ self.cur = None
141
+
142
+
143
+ # ------------------------------------------------------------------ report render
144
+ def _print_final(report):
145
+ v = threshold.verdict(report)
146
+ des = report.get("DES") or 0.0
147
+ print(" " + "=" * 72)
148
+ head = f" FINAL SCORE {des:.4f} ({int(round(des * 100))}/100) band: {v['band']}"
149
+ print(tui.ok(head) if v["passed"] else tui.bad(head))
150
+ if v["passed"]:
151
+ tag = f" VERDICT PASS — clears the {v['pass_des']} pass line with all gates green"
152
+ elif not v["gates_ok"]:
153
+ tag = " VERDICT GATED — failing gate(s): " + ", ".join(v["failed_gates"])
154
+ else:
155
+ tag = f" VERDICT below the {v['pass_des']} pass line"
156
+ print(tui.ok(tag) if v["passed"] else tui.bad(tag))
157
+ if report.get("errors"):
158
+ print(f" note: {report['errors']} task(s) errored (endpoint) and were excluded from scoring")
159
+ print(" " + "=" * 72)
160
+
161
+
162
+ def _render_scorecard(report):
163
+ print(f" {'CRITERION':16} {'SCORE':>6} METRICS")
164
+ for c, e in sorted(report["criteria"].items()):
165
+ extra = " ".join(f"{k}={x}" for k, x in e.items() if k not in ("score", "n"))
166
+ score = e["score"] if e["score"] is not None else "-"
167
+ print(f" {c:16} {score:>6} {extra}")
168
+ gline = " ".join(f"{k}:{g}" for k, g in report.get("gates", {}).items())
169
+ print(f" gates: {gline or 'n/a'}")
170
+
171
+
172
+ # ------------------------------------------------------------------ case browsing
173
+ def _show_case(io, r, breadcrumb):
174
+ tui.clear(); tui.header(breadcrumb + " / case")
175
+ out = r.get("output")
176
+ out = "\n---\n".join(str(o) for o in out) if isinstance(out, list) else str(out)
177
+ err = "endpoint_error" in (r.get("flags") or [])
178
+ status = "ERROR" if err else ("PASS" if (r.get("passed") and not r.get("flags")) else "FAIL")
179
+ flags = (" [" + ",".join(r["flags"]) + "]") if r.get("flags") else ""
180
+ print(f"\n {status} {r['criterion']} · {r['id']} · score {r.get('score')}{flags}\n")
181
+ print(" PROMPT"); print(_wrap(r.get("prompt"), 900))
182
+ print("\n EXPECTED"); print(" " + str(r.get("expected")))
183
+ print("\n MODEL OUTPUT"); print(_wrap(out, 1100))
184
+ print("\n GRADER SAID"); print(" " + (r.get("detail") or "(n/a)"))
185
+ tui.pause(io)
186
+
187
+
188
+ def _browse_group(io, rows, breadcrumb):
189
+ while True:
190
+ labels = []
191
+ for r in rows:
192
+ err = "endpoint_error" in (r.get("flags") or [])
193
+ mk = _mk(r.get("passed") and not r.get("flags"), err)
194
+ sc = " - " if r.get("score") is None else f"{r['score']:.2f}"
195
+ fl = (" [" + ",".join(r["flags"]) + "]") if r.get("flags") else ""
196
+ labels.append(f"{mk:3} {r['id']:16} {sc:>5}{fl}")
197
+ sel = tui.menu(labels + ["Back"], io, title="Enter to inspect the prompt / expected / output",
198
+ breadcrumb=breadcrumb + " / cases")
199
+ if sel is None or sel == len(rows):
200
+ return
201
+ _show_case(io, rows[sel], breadcrumb)
202
+
203
+
204
+ def _browse_cases(io, journal, breadcrumb, mode="all"):
205
+ def keep(r):
206
+ passed = r.get("passed") and not r.get("flags")
207
+ return {"failed": not passed, "passed": passed}.get(mode, True)
208
+ rows = [r for r in journal if keep(r)]
209
+ if not rows:
210
+ tui.clear(); tui.header(breadcrumb + " / cases")
211
+ print(f"\n No {mode} test cases in this run.")
212
+ tui.pause(io)
213
+ return
214
+ order = [c["key"] for c in CRITERIA]
215
+ groups = [(c, [r for r in rows if r["criterion"] == c]) for c in order]
216
+ groups = [(c, rs) for c, rs in groups if rs]
217
+ while True:
218
+ labels = []
219
+ for c, rs in groups:
220
+ npass = sum(1 for r in rs if r.get("passed") and not r.get("flags"))
221
+ labels.append(f"{_CRIT_NAME.get(c, c)[:32]:32} {npass}/{len(rs)} passed")
222
+ sel = tui.menu(labels + ["Back"], io,
223
+ title=f"{mode.upper()} cases by criterion ({len(rows)} tasks) — Enter a criterion",
224
+ breadcrumb=breadcrumb + " / cases")
225
+ if sel is None or sel == len(groups):
226
+ return
227
+ _browse_group(io, groups[sel][1], breadcrumb)
228
+
229
+
230
+ # ------------------------------------------------------------------ run flow
231
+ def _flow_run(io, adapter_factory):
232
+ mode = tui.menu(
233
+ ["Quick smoke — 2 per criterion (20 tasks · seconds)",
234
+ "Mid scan — 25 per criterion (250 tasks · minutes)",
235
+ "Full scan — all criteria (506 tasks · long)",
236
+ "Back"],
237
+ io, title="Choose scan depth", breadcrumb="Run a test")
238
+ if mode is None or mode == 3:
239
+ return
240
+
241
+ tui.clear(); tui.header("Run a test / target")
242
+ print("\n Paste the LLM endpoint — provider + model are auto-detected.")
243
+ print(" e.g. http://localhost:11434/v1 (ollama) · http://HOST:8000/v1 (vllm) · https://api.anthropic.com\n")
244
+ endpoint = io.text(" endpoint: ", "http://localhost:11434/v1").strip()
245
+ if not endpoint:
246
+ return
247
+ provider = _detect_provider(endpoint)
248
+ api_key = None
249
+ if provider == "anthropic":
250
+ api_key = io.text(" anthropic api key: ", "").strip() or None
251
+ models = CLAUDE_MODELS
252
+ else:
253
+ print(f"\n detected {provider} — fetching models ...")
254
+ models = _fetch_models(endpoint)
255
+ if len(models) == 1:
256
+ model = models[0]
257
+ elif len(models) > 1:
258
+ sel = tui.menu(models + ["(type it manually)"], io,
259
+ title=f"{provider}: pick the model", breadcrumb="Run a test / model")
260
+ model = models[sel] if (sel is not None and sel < len(models)) else io.text(" model: ", "").strip()
261
+ else:
262
+ model = io.text(" model name: ", "").strip()
263
+ if not model:
264
+ return
265
+
266
+ tasks = load_suite(_suite_default())
267
+ if mode == 0:
268
+ tasks = smoke_subset(tasks, 2)
269
+ elif mode == 1:
270
+ tasks = smoke_subset(tasks, 25)
271
+ label = {0: "smoke", 1: "mid", 2: "full"}[mode]
272
+
273
+ adapter = adapter_factory(endpoint, model, provider=provider, api_key=api_key)
274
+
275
+ tui.clear(); tui.header("Run a test / scanning")
276
+ print(f"\n target: {provider}:{model} @ {endpoint} ({len(tasks)} tasks · {label})")
277
+ try:
278
+ pong = adapter.complete([{"role": "user", "content": "Reply with: ok"}], max_tokens=8).text
279
+ print(" " + tui.ok(f"preflight ok (replied {pong.strip()[:30]!r})"))
280
+ except Exception as e: # noqa: BLE001
281
+ print(" " + tui.bad(f"preflight failed: {e}"))
282
+ tui.pause(io)
283
+ return
284
+ print("\n scanning — a score appears as each criterion finishes:\n")
285
+ _pulse(f"initializing {len(tasks)} tests")
286
+
287
+ prog = _Progress(tasks)
288
+ report, journal = run_suite(tasks, adapter, on_item=prog)
289
+ prog.finish()
290
+ report["model"] = f"{provider}:{model}"
291
+ run_id = history.new_run_id(model)
292
+ meta = {"model": f"{provider}:{model}", "endpoint": endpoint, "suite": label,
293
+ "n": len(tasks), "DES": report["DES"]}
294
+ history.save_run(run_id, report, journal, meta)
295
+
296
+ # FINAL SCORE + full detailed report, inline on the same screen
297
+ print()
298
+ _print_final(report)
299
+ print()
300
+ _render_scorecard(report)
301
+ print(f"\n saved as {run_id} — available anytime under Reports")
302
+ tui.pause(io, "press any key to inspect test cases ")
303
+
304
+ while True:
305
+ tui.clear(); tui.header("Run a test / report")
306
+ print()
307
+ _print_final(report)
308
+ print()
309
+ sel = tui.menu(["View ALL test cases (passed + failed)",
310
+ "View failed / flagged only", "Back to main menu"],
311
+ io, breadcrumb="Run a test / report")
312
+ if sel == 0:
313
+ _browse_cases(io, journal, "Run a test", "all")
314
+ elif sel == 1:
315
+ _browse_cases(io, journal, "Run a test", "failed")
316
+ else:
317
+ return
318
+
319
+
320
+ # ------------------------------------------------------------------ reports flow
321
+ def _flow_reports(io):
322
+ while True:
323
+ runs = history.list_runs()
324
+ if not runs:
325
+ tui.clear(); tui.header("Reports")
326
+ print("\n No saved scans yet. Run a test first.")
327
+ tui.pause(io)
328
+ return
329
+ labels = [f"{r['id']:30} {str(r.get('model'))[:20]:20} {str(r.get('suite', '')):5} DES {r.get('DES')}"
330
+ for r in runs]
331
+ sel = tui.menu(labels + ["Back"], io, title="Saved scans (newest first)", breadcrumb="Reports")
332
+ if sel is None or sel == len(runs):
333
+ return
334
+ data = history.load_run(runs[sel]["id"])
335
+ if not data:
336
+ continue
337
+ while True:
338
+ tui.clear(); tui.header("Reports / detail")
339
+ m = data["meta"]
340
+ print(f"\n {runs[sel]['id']} {m.get('model')} {m.get('endpoint', '')} ({m.get('n')} tasks)\n")
341
+ _print_final(data["report"])
342
+ print()
343
+ _render_scorecard(data["report"])
344
+ sub = tui.menu(["View ALL test cases (passed + failed)",
345
+ "View failed / flagged only", "Back to scan list"],
346
+ io, breadcrumb="Reports / detail")
347
+ if sub == 0:
348
+ _browse_cases(io, data["journal"], "Reports", "all")
349
+ elif sub == 1:
350
+ _browse_cases(io, data["journal"], "Reports", "failed")
351
+ else:
352
+ break
353
+
354
+
355
+ # ------------------------------------------------------------------ info flow
356
+ def _metrics_lines():
357
+ return ["METRICS & SCORING", "",
358
+ " Every task is graded 0..1 by a dedicated grader (deterministic where possible).",
359
+ " Per-criterion = mean of its tasks. FINAL SCORE (DES) = weighted composite:"] + \
360
+ [f" {c['n']:>2}. {c['name']:34} {int(c['weight'] * 100):>3}%" for c in CRITERIA] + \
361
+ ["", " Special metrics: refusal->ORR/CRR · vuln_ident->detection/false-alarm ·",
362
+ " planning->distinct-ratio · format->valid short/long · groundedness->fabrication-rate.",
363
+ " Endpoint errors are retried, then excluded from scoring (never graded as answers)."]
364
+
365
+
366
+ def _gates_lines():
367
+ out = ["HARD GATES (auto-flag even at a high score)", ""]
368
+ out += [f" · {n:14} {d}" for n, d in GATES]
369
+ out += ["", "DATA POOLS (contamination policy)", ""]
370
+ out += [f" · {n:14} {d}" for n, d in POOLS]
371
+ return out
372
+
373
+
374
+ def _working_lines():
375
+ return ["HOW THE BENCHMARK WORKS", "",
376
+ " 1. A suite = task files (~50 per criterion; 25 mid, 2 smoke).",
377
+ " 2. Each task is sent to your endpoint — the model is isolated from the agent.",
378
+ " 3. The matching grader scores the raw output. No agent loop, no tools.",
379
+ " 4. Scores roll up to per-criterion + a weighted FINAL SCORE; gates are checked.",
380
+ " 5. The run is saved with the real prompts + outputs so you can review every case.",
381
+ "", " Model-agnostic · contamination-safe · reproducible (temp 0, fixed seed)."]
382
+
383
+
384
+ def _info_page(io, title, lines):
385
+ tui.clear(); tui.header("Info / " + title)
386
+ print()
387
+ for ln in lines:
388
+ print(" " + ln)
389
+ tui.pause(io)
390
+
391
+
392
+ def _info_criteria(io):
393
+ while True:
394
+ labels = [f"{c['n']:>2}. {c['name']:34} [{int(c['weight'] * 100)}% · tier {c['tier']}]"
395
+ for c in CRITERIA]
396
+ sel = tui.menu(labels + ["Back"], io,
397
+ title="The 10 criteria — pick one for full detail", breadcrumb="Info / criteria")
398
+ if sel is None or sel == len(CRITERIA):
399
+ return
400
+ c = CRITERIA[sel]
401
+ lines = [f"CRITERION {c['n']} — {c['name']}", "",
402
+ f" weight {int(c['weight'] * 100)}% · tier {c['tier']} · graders: {', '.join(c['graders'])}",
403
+ "", "MEASURES", " " + c["measures"],
404
+ "", "HOW IT IS JUDGED", " " + c["judged"],
405
+ "", "WHY IT MATTERS", " " + c["why"],
406
+ "", "EXAMPLE", " " + c["example"]]
407
+ _info_page(io, c["name"], lines)
408
+
409
+
410
+ def _flow_info(io):
411
+ while True:
412
+ sel = tui.menu(
413
+ ["Browse the 10 criteria (measures, judging, why, example)",
414
+ "Metrics & scoring", "Gates & data pools",
415
+ "Ideal threshold (how it was set)", "How the benchmark works", "Back"],
416
+ io, title="What do you want to know?", breadcrumb="Info")
417
+ if sel is None or sel == 5:
418
+ return
419
+ if sel == 0:
420
+ _info_criteria(io)
421
+ elif sel == 1:
422
+ _info_page(io, "Metrics & scoring", _metrics_lines())
423
+ elif sel == 2:
424
+ _info_page(io, "Gates & data pools", _gates_lines())
425
+ elif sel == 3:
426
+ _info_page(io, "Ideal threshold", threshold.RATIONALE.splitlines())
427
+ elif sel == 4:
428
+ _info_page(io, "How the benchmark works", _working_lines())
429
+
430
+
431
+ # ------------------------------------------------------------------ main loop
432
+ def run_app(io=None, adapter_factory=None):
433
+ if io is None:
434
+ io = tui.IO()
435
+ if adapter_factory is None:
436
+ from .adapter import ModelAdapter
437
+ adapter_factory = ModelAdapter
438
+
439
+ tui.enable_utf8()
440
+ tui.clear()
441
+ banner.print_brand(big=True)
442
+ print("\n Interactive mode — use the arrow keys. Press Enter to begin.")
443
+ io.key()
444
+
445
+ while True:
446
+ sel = tui.menu(
447
+ ["Run a test", "Reports (view past scans)", "Info (criteria, metrics, how it works)", "Quit"],
448
+ io, title="Main menu", hint="up/down move | Enter select | Esc/Q quit")
449
+ if sel is None or sel == 3:
450
+ tui.clear()
451
+ print(" bye.")
452
+ return
453
+ if sel == 0:
454
+ _flow_run(io, adapter_factory)
455
+ elif sel == 1:
456
+ _flow_reports(io)
457
+ elif sel == 2:
458
+ _flow_info(io)
dreval/banner.py ADDED
@@ -0,0 +1,71 @@
1
+ """DarkRange-Eval branding — a horizontal lockup: the company icon on the LEFT
2
+ and the DARKRANGE wordmark on the RIGHT (like darkrange-logo-full-white.svg),
3
+ in red, on every screen. Pure-ASCII fallback when truecolor isn't available."""
4
+ import sys
5
+
6
+ try:
7
+ from .logo import LOGO_ANSI, LOGO_BIG # red half-block icons: 20px (header), 32px (splash)
8
+ except Exception:
9
+ LOGO_ANSI = LOGO_BIG = ""
10
+
11
+ # Compact "DARKRANGE" wordmark that fits beside the icon (6 rows).
12
+ DR_TEXT = r""" ██████╗ █████╗ ██████╗ ██╗ ██╗██████╗ █████╗ ███╗ ██╗ ██████╗ ███████╗
13
+ ██╔══██╗██╔══██╗██╔══██╗██║ ██╔╝██╔══██╗██╔══██╗████╗ ██║██╔════╝ ██╔════╝
14
+ ██║ ██║███████║██████╔╝█████╔╝ ██████╔╝███████║██╔██╗ ██║██║ ███╗█████╗
15
+ ██║ ██║██╔══██║██╔══██╗██╔═██╗ ██╔══██╗██╔══██║██║╚██╗██║██║ ██║██╔══╝
16
+ ██████╔╝██║ ██║██║ ██║██║ ██╗██║ ██║██║ ██║██║ ╚████║╚██████╔╝███████╗
17
+ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚══════╝"""
18
+
19
+ RED = "\033[38;5;196m"
20
+ RST = "\033[0m"
21
+
22
+
23
+ def _ascii_mode():
24
+ import os
25
+ return bool(os.environ.get("DR_EVAL_ASCII"))
26
+
27
+
28
+ def _use_color():
29
+ import os
30
+ return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
31
+
32
+
33
+ def _lockup(icon_str):
34
+ """icon (left) + DARKRANGE text (right, vertically centered) on the same rows."""
35
+ color = _use_color()
36
+ icon = icon_str.split("\n")
37
+ text = DR_TEXT.split("\n")
38
+ pad = max(0, (len(icon) - len(text)) // 2)
39
+ lines = []
40
+ for r, left in enumerate(icon):
41
+ ti = r - pad
42
+ right = text[ti] if 0 <= ti < len(text) else ""
43
+ lines.append(left + " " + ((RED + right + RST) if (color and right) else right))
44
+ return "\n".join(lines)
45
+
46
+
47
+ def _safe_print(text):
48
+ try:
49
+ sys.stdout.write(text)
50
+ except UnicodeEncodeError:
51
+ sys.stdout.buffer.write(text.encode('utf-8'))
52
+
53
+ def print_brand(big=False):
54
+ """The DarkRange logo lockup — shown on every screen. big=True for the splash."""
55
+ color = _use_color()
56
+ icon = LOGO_BIG if big else LOGO_ANSI
57
+ if _ascii_mode() or not color or not icon:
58
+ # no truecolor icon available -> just the DARKRANGE wordmark
59
+ _safe_print((RED + DR_TEXT + RST + "\n") if color else DR_TEXT + "\n")
60
+ else:
61
+ _safe_print(_lockup(icon) + "\n")
62
+ sys.stdout.flush()
63
+
64
+
65
+ # back-compat aliases
66
+ def print_wordmark():
67
+ print_brand()
68
+
69
+
70
+ def print_banner(subtitle="", version="0.1.0"):
71
+ print_brand()