ecdat 0.2.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.
Files changed (51) hide show
  1. ecdat/__init__.py +10 -0
  2. ecdat/__main__.py +5 -0
  3. ecdat/cli/__init__.py +203 -0
  4. ecdat/cli/commands/__init__.py +0 -0
  5. ecdat/cli/commands/about.py +130 -0
  6. ecdat/cli/commands/demo.py +116 -0
  7. ecdat/cli/commands/doctor.py +296 -0
  8. ecdat/cli/commands/help_cmd.py +205 -0
  9. ecdat/cli/commands/scan.py +228 -0
  10. ecdat/cli/commands/version_cmd.py +48 -0
  11. ecdat/cli/parser.py +87 -0
  12. ecdat/demo_project/auth/login.py +75 -0
  13. ecdat/demo_project/certs/cert_verify.go +81 -0
  14. ecdat/demo_project/keyexchange/channel.go +48 -0
  15. ecdat/demo_project/legacy/LegacyCrypto.java +78 -0
  16. ecdat/demo_project/payments/payment.py +64 -0
  17. ecdat/demo_project/quantum/pqc_utils.py +67 -0
  18. ecdat/demo_project/quantum/slh_signer.py +40 -0
  19. ecdat/demo_project/tokens/signing.js +54 -0
  20. ecdat/py.typed +0 -0
  21. ecdat/services/__init__.py +1 -0
  22. ecdat/services/crashlog.py +109 -0
  23. ecdat/services/demo.py +85 -0
  24. ecdat/services/paths.py +52 -0
  25. ecdat/services/scanner.py +248 -0
  26. ecdat/services/viewmodel.py +326 -0
  27. ecdat/ui/__init__.py +1 -0
  28. ecdat/ui/art3d.py +136 -0
  29. ecdat/ui/art_static.py +65 -0
  30. ecdat/ui/art_text.py +81 -0
  31. ecdat/ui/banner.py +148 -0
  32. ecdat/ui/console.py +119 -0
  33. ecdat/ui/motion.py +64 -0
  34. ecdat/ui/render.py +486 -0
  35. ecdat/ui/theme.py +173 -0
  36. ecdat-0.2.0.dist-info/METADATA +142 -0
  37. ecdat-0.2.0.dist-info/RECORD +51 -0
  38. ecdat-0.2.0.dist-info/WHEEL +5 -0
  39. ecdat-0.2.0.dist-info/entry_points.txt +2 -0
  40. ecdat-0.2.0.dist-info/licenses/LICENSE +21 -0
  41. ecdat-0.2.0.dist-info/top_level.txt +2 -0
  42. ecdat_core/__init__.py +6 -0
  43. ecdat_core/cbom_export.py +287 -0
  44. ecdat_core/cli.py +202 -0
  45. ecdat_core/detector.py +273 -0
  46. ecdat_core/ingestion.py +581 -0
  47. ecdat_core/models.py +145 -0
  48. ecdat_core/recommender.py +74 -0
  49. ecdat_core/risk_engine.py +264 -0
  50. ecdat_core/signature_loader.py +204 -0
  51. ecdat_core/signatures.json +692 -0
ecdat/ui/render.py ADDED
@@ -0,0 +1,486 @@
1
+ """Rich report renderers for ECDAT scan results.
2
+
3
+ Every renderer consumes :class:`~ecdat.services.viewmodel.ScanVM` and
4
+ :class:`~ecdat.services.viewmodel.FindingVM` — never raw engine models.
5
+ ALL dynamic values (file paths, snippets, algorithm names, rationales) are
6
+ wrapped in :class:`rich.text.Text` — they are NEVER interpolated into Rich
7
+ markup strings because they come from attacker-controlled scanned repos.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import List, Optional
13
+
14
+ from rich.console import Group, RenderableType
15
+ from rich.panel import Panel
16
+ from rich.style import Style
17
+ from rich.table import Table
18
+ from rich.text import Text
19
+
20
+ from ecdat.services.viewmodel import FindingVM, ScanVM
21
+ from ecdat.ui.art_static import emblem_for, headline_for, verdict
22
+ from ecdat.ui.theme import (
23
+ MINT_GRADIENT,
24
+ PALETTE,
25
+ RISK_COLORS,
26
+ RISK_LABELS,
27
+ RISK_ORDER,
28
+ )
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Character sets
32
+ # ---------------------------------------------------------------------------
33
+
34
+ _UNICODE_EIGHTHS = "\u258f\u258e\u258d\u258c\u258b\u258a\u2589\u2588" # ▏▎▍▌▋▊▉█
35
+ """Eighth-block characters, index 0 = 1/8, index 7 = 8/8 (full block)."""
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Helpers
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ def _safe(value: str) -> Text:
43
+ """Wrap a string in a plain :class:`~rich.text.Text` — no markup parsing."""
44
+ return Text(value, style=Style())
45
+
46
+
47
+ def _ellipsis_path(path: str, line: int | None, width: int = 50) -> str:
48
+ """Ellipsise a file path in the middle, preserving the filename.
49
+
50
+ ``"very/long/path/to/some/file.py:123"`` →
51
+ ``"very/lo…/some/file.py:123"`` (or similar).
52
+ """
53
+ suffix = f":{line}" if line is not None else ""
54
+ full = f"{path}{suffix}"
55
+ if len(full) <= width:
56
+ return full
57
+
58
+ # Keep filename + preceding segment on the right, prefix on the left.
59
+ parts = path.split("/")
60
+ if len(parts) <= 2:
61
+ # Can't really ellipsise — truncate right.
62
+ return full[: width - 1] + "\u2026"
63
+
64
+ # Keep last 2 segments on the right.
65
+ right = "/".join(parts[-2:]) + suffix
66
+ left_budget = width - len(right) - 1 # -1 for the ellipsis sigil
67
+ if left_budget < 3:
68
+ return f"\u2026{right}"[:width]
69
+
70
+ left = parts[0]
71
+ i = 1
72
+ while i < len(parts) - 2 and len(left) + 1 + len(parts[i]) <= left_budget:
73
+ left = f"{left}/{parts[i]}"
74
+ i += 1
75
+
76
+ return f"{left}\u2026/{right}"
77
+
78
+
79
+ def _flag_text(finding: FindingVM) -> str:
80
+ """Return flag letters for a finding: ``Q`` = quantum-vulnerable, ``!`` = classically broken."""
81
+ flags = []
82
+ if finding.quantum_vulnerable:
83
+ flags.append("Q")
84
+ if finding.classically_broken:
85
+ flags.append("!")
86
+ return " ".join(flags) if flags else "\u2014"
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Public renderers
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ def smooth_bar(
95
+ fraction: float,
96
+ width: int,
97
+ *,
98
+ unicode: bool = True,
99
+ ) -> Text:
100
+ """Render a horizontal progress bar with eighth-block precision.
101
+
102
+ Args:
103
+ fraction: Fill ratio, clamped to ``[0, 1]``.
104
+ width: Total bar width in cells.
105
+ unicode: Use Unicode eighth-blocks (``True``) or ASCII ``#``/``-``.
106
+
107
+ Returns:
108
+ A :class:`~rich.text.Text` of exactly *width* cells.
109
+ """
110
+ fraction = max(0.0, min(1.0, fraction))
111
+ width = max(1, width)
112
+
113
+ filled = fraction * width
114
+ full_cells = int(filled)
115
+ frac_part = filled - full_cells
116
+
117
+ result = Text()
118
+ accent_style = Style(color=PALETTE.accent)
119
+ muted_style = Style(color=PALETTE.muted)
120
+
121
+ for i in range(width):
122
+ if i < full_cells:
123
+ if unicode:
124
+ result.append("\u2588", style=accent_style) # full block
125
+ else:
126
+ result.append("#", style=accent_style)
127
+ elif i == full_cells and frac_part > 0:
128
+ eig_idx = min(7, int(frac_part * 8))
129
+ if unicode and eig_idx > 0:
130
+ result.append(_UNICODE_EIGHTHS[eig_idx - 1], style=accent_style)
131
+ elif unicode:
132
+ result.append(" ", style=muted_style)
133
+ else:
134
+ result.append("-", style=muted_style)
135
+ else:
136
+ if unicode:
137
+ result.append(" ", style=muted_style)
138
+ else:
139
+ result.append("-", style=muted_style)
140
+
141
+ return result
142
+
143
+
144
+ def risk_chip(level: str) -> Text:
145
+ """Return a short coloured label for *level* (e.g. ``"CRITICAL"``).
146
+
147
+ Uses :data:`~ecdat.ui.theme.RISK_COLORS` and
148
+ :data:`~ecdat.ui.theme.RISK_LABELS`.
149
+ """
150
+ color = RISK_COLORS.get(level, PALETTE.text)
151
+ label = RISK_LABELS.get(level, level.upper())
152
+ return Text(label, style=Style(color=color, bold=True))
153
+
154
+
155
+ def summary_panel(vm: ScanVM) -> Panel:
156
+ """Render the scan summary panel with padlock emblem and headline."""
157
+ v = verdict(vm.counts)
158
+ emblem = emblem_for(v)
159
+ headline = headline_for(vm.counts, vm.files_scanned)
160
+
161
+ # Compute safe percentage.
162
+ safe_pct = round(vm.safe_ratio * 100)
163
+
164
+ # Build the right-side content.
165
+ right = Text()
166
+ hl = _safe(headline)
167
+ hl.stylize(Style(bold=True))
168
+ right.append(hl)
169
+ right.append("\n\n")
170
+
171
+ # Target.
172
+ right.append("Target ", style=Style(color=PALETTE.muted))
173
+ right.append(_safe(vm.target))
174
+ right.append("\n")
175
+
176
+ # Duration.
177
+ dur = f"{vm.duration_s:.1f}s" if vm.duration_s is not None else "N/A"
178
+ right.append("Duration ", style=Style(color=PALETTE.muted))
179
+ right.append(_safe(dur))
180
+ right.append("\n")
181
+
182
+ # Files scanned.
183
+ right.append("Files ", style=Style(color=PALETTE.muted))
184
+ right.append(_safe(str(vm.files_scanned)))
185
+ right.append("\n")
186
+
187
+ # Quantum-safe percentage.
188
+ right.append("Safe ", style=Style(color=PALETTE.muted))
189
+ right.append(f"{safe_pct}%", style=Style(color=PALETTE.safe, bold=True))
190
+
191
+ # Build the emblem as left-side content.
192
+ left_text = Text()
193
+ for i, row in enumerate(emblem):
194
+ if i > 0:
195
+ left_text.append("\n")
196
+ # Colour the emblem.
197
+ color = {"crit": PALETTE.critical, "warn": PALETTE.high}.get(
198
+ v, PALETTE.safe
199
+ )
200
+ left_text.append(row, style=Style(color=color, bold=True))
201
+
202
+ # Build layout with columns.
203
+ content = Table.grid(padding=(0, 2))
204
+ content.add_column(justify="left", vertical="top")
205
+ content.add_column(justify="left", vertical="top")
206
+ content.add_row(left_text, right)
207
+
208
+ return Panel(
209
+ content,
210
+ title="Scan Summary",
211
+ title_align="left",
212
+ border_style=PALETTE.border,
213
+ padding=(1, 2),
214
+ )
215
+
216
+
217
+ def risk_bars(vm: ScanVM) -> RenderableType:
218
+ """Render a horizontal stacked risk-distribution bar.
219
+
220
+ Each risk level gets a segment proportional to its count. Returns a
221
+ :class:`~rich.table.Table` showing a stacked bar and a legend row.
222
+ """
223
+ total = vm.total
224
+ grid = Table.grid(padding=(0, 1))
225
+ grid.add_column(justify="left")
226
+
227
+ # Stacked bar row.
228
+ if total == 0:
229
+ bar_text = Text(" " * 40, style=Style(color=PALETTE.muted, bgcolor=PALETTE.surface))
230
+ grid.add_row(bar_text)
231
+ else:
232
+ bar = Text()
233
+ for level in RISK_ORDER:
234
+ count = vm.counts.get(level, 0)
235
+ if count == 0:
236
+ continue
237
+ seg_width = max(1, round(count / total * 40))
238
+ color = RISK_COLORS.get(level, PALETTE.muted)
239
+ bar.append("\u2588" * seg_width, style=Style(color=color, bgcolor=color))
240
+ # Pad to exactly 40 cells.
241
+ current = len(bar.plain)
242
+ if current < 40:
243
+ bar.append(" " * (40 - current))
244
+ grid.add_row(bar)
245
+
246
+ # Legend row.
247
+ legend_parts: list[Text] = []
248
+ for level in RISK_ORDER:
249
+ count = vm.counts.get(level, 0)
250
+ color = RISK_COLORS.get(level, PALETTE.muted)
251
+ label = RISK_LABELS.get(level, level)
252
+ part = Text(f" {label}:{count} ", style=Style(color=color))
253
+ legend_parts.append(part)
254
+
255
+ legend_row = Text()
256
+ for i, part in enumerate(legend_parts):
257
+ if i > 0:
258
+ legend_row.append(" ")
259
+ legend_row.append(part)
260
+ grid.add_row(legend_row)
261
+
262
+ return grid
263
+
264
+
265
+ def findings_table(vm: ScanVM, *, limit: int = 15) -> Table:
266
+ """Render the findings table.
267
+
268
+ Columns: Risk, Algorithm, Location (file:line ellipsised), Conf %, Flags.
269
+ Shows at most *limit* rows; a ``"+N more"`` row is appended when truncated.
270
+
271
+ Args:
272
+ vm: The scan view-model.
273
+ limit: Maximum findings to display.
274
+
275
+ Returns:
276
+ A :class:`~rich.table.Table`.
277
+ """
278
+ table = Table(
279
+ title="Findings",
280
+ title_style=f"bold {PALETTE.accent}",
281
+ border_style=PALETTE.border,
282
+ header_style=Style(color=PALETTE.muted, bold=True),
283
+ expand=False,
284
+ pad_edge=True,
285
+ )
286
+ table.add_column("Risk", style="bold", no_wrap=True)
287
+ table.add_column("Algorithm", no_wrap=True)
288
+ table.add_column("Location", no_wrap=True)
289
+ table.add_column("Conf %", justify="right", no_wrap=True)
290
+ table.add_column("Flags", no_wrap=True)
291
+
292
+ displayed = vm.findings[:limit]
293
+ for f in displayed:
294
+ loc = _ellipsis_path(f.file_path, f.line)
295
+ conf = f"{f.confidence * 100:.0f}%"
296
+ flags = _flag_text(f)
297
+
298
+ # Risk chip.
299
+ risk_cell = risk_chip(f.risk_level)
300
+
301
+ table.add_row(
302
+ risk_cell,
303
+ _safe(f.algorithm),
304
+ _safe(loc),
305
+ _safe(conf),
306
+ _safe(flags),
307
+ )
308
+
309
+ # "+N more" row if truncated.
310
+ remaining = len(vm.findings) - limit
311
+ if remaining > 0:
312
+ more = Text(f"+{remaining} more", style=Style(color=PALETTE.muted, italic=True))
313
+ table.add_row(more, Text(), Text(), Text(), Text())
314
+
315
+ return table
316
+
317
+
318
+ def priority_actions_panel(vm: ScanVM, *, limit: int = 8) -> Panel:
319
+ """Render the priority-actions panel.
320
+
321
+ Groups findings by ``(family, file_path)``, ranked worst-first. Shows at
322
+ most *limit* rows.
323
+
324
+ Args:
325
+ vm: The scan view-model.
326
+ limit: Maximum priority actions to display.
327
+
328
+ Returns:
329
+ A :class:`~rich.panel.Panel`.
330
+ """
331
+ actions = vm.priority_actions[:limit]
332
+
333
+ table = Table(
334
+ border_style=PALETTE.border,
335
+ header_style=Style(color=PALETTE.muted, bold=True),
336
+ expand=False,
337
+ pad_edge=False,
338
+ )
339
+ table.add_column("Risk", no_wrap=True)
340
+ table.add_column("Family", no_wrap=True)
341
+ table.add_column("File", no_wrap=False)
342
+ table.add_column("#", justify="right", no_wrap=True)
343
+ table.add_column("Recommend", no_wrap=True)
344
+
345
+ for pa in actions:
346
+ loc = _ellipsis_path(pa.file_path, None, width=40)
347
+ table.add_row(
348
+ risk_chip(pa.worst_risk),
349
+ _safe(pa.family),
350
+ _safe(loc),
351
+ _safe(str(pa.count)),
352
+ _safe(pa.recommended),
353
+ )
354
+
355
+ if not actions:
356
+ table.add_row(
357
+ Text("No priority actions", style=Style(color=PALETTE.safe, italic=True)),
358
+ Text(), Text(), Text(), Text(),
359
+ )
360
+
361
+ return Panel(
362
+ table,
363
+ title="Priority Actions",
364
+ title_align="left",
365
+ border_style=PALETTE.border,
366
+ padding=(0, 1),
367
+ )
368
+
369
+
370
+ def recommendations_table(vm: ScanVM) -> Table:
371
+ """Render the recommendations table (algorithm → PQC replacement).
372
+
373
+ Returns:
374
+ A :class:`~rich.table.Table`.
375
+ """
376
+ table = Table(
377
+ title="Recommendations",
378
+ title_style=f"bold {PALETTE.accent}",
379
+ border_style=PALETTE.border,
380
+ header_style=Style(color=PALETTE.muted, bold=True),
381
+ expand=False,
382
+ pad_edge=True,
383
+ )
384
+ table.add_column("Algorithm")
385
+ table.add_column("Count", justify="right")
386
+ table.add_column("Recommendation")
387
+
388
+ for rec_name, findings in sorted(vm.by_recommendation.items()):
389
+ if rec_name == "(none)":
390
+ continue
391
+ # Gather unique families that have this recommendation.
392
+ families = sorted({f.family for f in findings})
393
+ table.add_row(
394
+ _safe(", ".join(families)),
395
+ _safe(str(len(findings))),
396
+ _safe(rec_name),
397
+ )
398
+
399
+ if not table.rows:
400
+ table.add_row(
401
+ Text("All algorithms are quantum-safe", style=Style(color=PALETTE.safe, italic=True)),
402
+ Text(),
403
+ Text(),
404
+ )
405
+
406
+ return table
407
+
408
+
409
+ def legend() -> Panel:
410
+ """Render a legend panel explaining flags, risk levels, and colours.
411
+
412
+ Returns:
413
+ A :class:`~rich.panel.Panel`.
414
+ """
415
+ content = Text()
416
+ content.append("Flags ", style=Style(bold=True))
417
+ content.append("Q = quantum-vulnerable ")
418
+ content.append("! = classically broken\n", style=Style(color=PALETTE.critical))
419
+ content.append("Risk levels: ", style=Style(bold=True))
420
+ for level in RISK_ORDER:
421
+ chip = risk_chip(level)
422
+ content.append(chip)
423
+ content.append(" ")
424
+
425
+ return Panel(
426
+ content,
427
+ title="Legend",
428
+ title_align="left",
429
+ border_style=PALETTE.border,
430
+ padding=(1, 2),
431
+ )
432
+
433
+
434
+ def scan_report(
435
+ vm: ScanVM,
436
+ *,
437
+ limit: int = 15,
438
+ unicode: bool = True,
439
+ ) -> Group:
440
+ """Render the complete scan report as a :class:`~rich.console.Group`.
441
+
442
+ When the scan found no cryptographic artefacts (:attr:`ScanVM.total` == 0),
443
+ a single friendly panel is returned instead of the full layout.
444
+
445
+ Args:
446
+ vm: The scan view-model.
447
+ limit: Maximum findings to display in the table.
448
+ unicode: Use Unicode characters (``True``) or ASCII fallback.
449
+
450
+ Returns:
451
+ A :class:`~rich.console.Group` ready for ``console.print()``.
452
+ """
453
+ # Empty scan — special case.
454
+ if vm.total == 0:
455
+ no_results = Text(
456
+ f"No cryptographic artefacts detected in {vm.files_scanned} files",
457
+ style=Style(color=PALETTE.safe),
458
+ )
459
+ no_results.justify = "center"
460
+ return Group(
461
+ Panel(
462
+ no_results,
463
+ border_style=PALETTE.border,
464
+ padding=(2, 4),
465
+ )
466
+ )
467
+
468
+ elements: List[RenderableType] = [
469
+ summary_panel(vm),
470
+ Text(), # spacer
471
+ risk_bars(vm),
472
+ Text(), # spacer
473
+ findings_table(vm, limit=limit),
474
+ ]
475
+
476
+ # Priority actions — only if there are non-quantum-safe findings.
477
+ if vm.priority_actions:
478
+ elements.append(Text()) # spacer
479
+ elements.append(priority_actions_panel(vm))
480
+
481
+ elements.append(Text()) # spacer
482
+ elements.append(recommendations_table(vm))
483
+ elements.append(Text()) # spacer
484
+ elements.append(legend())
485
+
486
+ return Group(*elements)
ecdat/ui/theme.py ADDED
@@ -0,0 +1,173 @@
1
+ """Frozen palette, colour utilities, and Rich theme for the ECDAT app layer.
2
+
3
+ Colours are derived from the dashboard design tokens (``frontend/src/index.css``)
4
+ and the shared risk palette (``frontend/src/lib/colors.ts``). All values are
5
+ immutable — palette is a frozen dataclass, and the module-level mappings are tuples
6
+ or frozensets so nothing accidentally mutates the shared state.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Tuple
13
+
14
+ from rich.theme import Theme
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Palette
18
+ # ---------------------------------------------------------------------------
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Palette:
23
+ """Immutable colour palette derived from the dashboard design tokens.
24
+
25
+ The default values are hard-coded hex colours that match the dashboard's
26
+ dark-only theme. Where the dashboard uses ``oklch(…)`` expressions the
27
+ approximate sRGB hex is given.
28
+ """
29
+
30
+ bg: str = "#0b0f0e" # ~dashboard --ecdat-bg #04100b — brightened for terminal
31
+ surface: str = "#111816" # ~dashboard --ecdat-surface #0b1a12 — brightened
32
+ panel: str = "#17211e" # panel background (card on surface)
33
+ border: str = "#26332f" # oklch(1 0 0 / 12%) ≈ #26332f
34
+ text: str = "#dfeae6" # oklch(0.97 0.005 160)
35
+ muted: str = "#8aa39a" # oklch(0.65 0.015 160)
36
+ accent: str = "#3ddc97" # dashboard --ecdat-accent #44e0a4 (softened for terminal)
37
+
38
+ # Risk-level colours — bright terminal-safe hex values distinct from the
39
+ # dashboard's Tailwind-700 shades (which are tuned for WCAG AA on white).
40
+ critical: str = "#ff5d5d"
41
+ high: str = "#ff9a3c"
42
+ medium: str = "#ffd23f"
43
+ low: str = "#4cc3ff"
44
+ safe: str = "#4ade80" # quantum-safe green
45
+
46
+
47
+ # Singleton instance — import this everywhere.
48
+ PALETTE = Palette()
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Risk metadata
52
+ # ---------------------------------------------------------------------------
53
+
54
+ RISK_ORDER: Tuple[str, ...] = ("critical", "high", "medium", "low", "quantum-safe")
55
+
56
+ RISK_COLORS: dict[str, str] = {
57
+ "critical": PALETTE.critical,
58
+ "high": PALETTE.high,
59
+ "medium": PALETTE.medium,
60
+ "low": PALETTE.low,
61
+ "quantum-safe": PALETTE.safe,
62
+ }
63
+
64
+ RISK_LABELS: dict[str, str] = {
65
+ "critical": "CRITICAL",
66
+ "high": "HIGH",
67
+ "medium": "MEDIUM",
68
+ "low": "LOW",
69
+ "quantum-safe": "SAFE",
70
+ }
71
+
72
+ RISK_INDEX: dict[str, int] = {level: i for i, level in enumerate(RISK_ORDER)}
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Colour helpers
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def hex_to_rgb(hex_color: str) -> Tuple[int, int, int]:
80
+ """Convert a ``#RRGGBB`` string to an ``(r, g, b)`` tuple (0–255)."""
81
+ hex_color = hex_color.lstrip("#")
82
+ return (
83
+ int(hex_color[0:2], 16),
84
+ int(hex_color[2:4], 16),
85
+ int(hex_color[4:6], 16),
86
+ )
87
+
88
+
89
+ def rgb_to_hex(r: int, g: int, b: int) -> str:
90
+ """Convert ``(r, g, b)`` (0–255) to a ``#RRGGBB`` string."""
91
+ return f"#{r:02x}{g:02x}{b:02x}"
92
+
93
+
94
+ def lerp_hex(a: str, b: str, t: float) -> str:
95
+ """Linearly interpolate between two hex colours.
96
+
97
+ ``t`` is clamped to ``[0, 1]``. Per-channel linear interpolation in sRGB.
98
+ """
99
+ t = max(0.0, min(1.0, t))
100
+ ar, ag, ab = hex_to_rgb(a)
101
+ br, bg, bb = hex_to_rgb(b)
102
+ return rgb_to_hex(
103
+ round(ar + (br - ar) * t),
104
+ round(ag + (bg - ag) * t),
105
+ round(ab + (bb - ab) * t),
106
+ )
107
+
108
+
109
+ def gradient(stops: list[str], steps: int) -> list[str]:
110
+ """Return ``steps`` evenly-spaced colours interpolating through *stops*.
111
+
112
+ The first colour is *stops[0]* and the last is *stops[-1]* exactly.
113
+ Interior stops are placed at evenly-spaced indices; linear interpolation
114
+ fills the gaps between them.
115
+
116
+ Args:
117
+ stops: Two or more hex-colour anchor points.
118
+ steps: Total number of colours to return (must be ≥ 2).
119
+
120
+ Returns:
121
+ A list of ``steps`` ``#RRGGBB`` strings.
122
+ """
123
+ if steps < 2:
124
+ return [stops[0]] * steps if steps == 1 else []
125
+ if len(stops) == 1:
126
+ return [stops[0]] * steps
127
+
128
+ n_segments = len(stops) - 1
129
+ result: list[str] = []
130
+ for i in range(steps):
131
+ # Map step index i to a float position in [0, n_segments].
132
+ pos = i * n_segments / (steps - 1)
133
+ seg = min(int(pos), n_segments - 1)
134
+ frac = pos - seg
135
+ result.append(lerp_hex(stops[seg], stops[seg + 1], frac))
136
+ return result
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Gradient presets
141
+ # ---------------------------------------------------------------------------
142
+
143
+ MINT_GRADIENT: list[str] = gradient(
144
+ ["#0b3d2e", "#1f9d6b", PALETTE.accent, "#eafff6"],
145
+ 12,
146
+ )
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Rich theme
150
+ # ---------------------------------------------------------------------------
151
+
152
+
153
+ def rich_theme() -> Theme:
154
+ """Build a :class:`rich.theme.Theme` from the :class:`Palette`.
155
+
156
+ Style names use the ``ecdat.`` prefix so they never clash with Rich's
157
+ built-in styles. Usable as:
158
+
159
+ console = Console(theme=rich_theme())
160
+ console.print("[ecdat.accent]hi[/]")
161
+ """
162
+ return Theme(
163
+ {
164
+ "ecdat.accent": f"bold {PALETTE.accent}",
165
+ "ecdat.muted": PALETTE.muted,
166
+ "ecdat.title": f"bold {PALETTE.accent}",
167
+ "ecdat.critical": f"bold {PALETTE.critical}",
168
+ "ecdat.high": f"bold {PALETTE.high}",
169
+ "ecdat.medium": PALETTE.medium,
170
+ "ecdat.low": PALETTE.low,
171
+ "ecdat.safe": PALETTE.safe,
172
+ }
173
+ )