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.
- ecdat/__init__.py +10 -0
- ecdat/__main__.py +5 -0
- ecdat/cli/__init__.py +203 -0
- ecdat/cli/commands/__init__.py +0 -0
- ecdat/cli/commands/about.py +130 -0
- ecdat/cli/commands/demo.py +116 -0
- ecdat/cli/commands/doctor.py +296 -0
- ecdat/cli/commands/help_cmd.py +205 -0
- ecdat/cli/commands/scan.py +228 -0
- ecdat/cli/commands/version_cmd.py +48 -0
- ecdat/cli/parser.py +87 -0
- ecdat/demo_project/auth/login.py +75 -0
- ecdat/demo_project/certs/cert_verify.go +81 -0
- ecdat/demo_project/keyexchange/channel.go +48 -0
- ecdat/demo_project/legacy/LegacyCrypto.java +78 -0
- ecdat/demo_project/payments/payment.py +64 -0
- ecdat/demo_project/quantum/pqc_utils.py +67 -0
- ecdat/demo_project/quantum/slh_signer.py +40 -0
- ecdat/demo_project/tokens/signing.js +54 -0
- ecdat/py.typed +0 -0
- ecdat/services/__init__.py +1 -0
- ecdat/services/crashlog.py +109 -0
- ecdat/services/demo.py +85 -0
- ecdat/services/paths.py +52 -0
- ecdat/services/scanner.py +248 -0
- ecdat/services/viewmodel.py +326 -0
- ecdat/ui/__init__.py +1 -0
- ecdat/ui/art3d.py +136 -0
- ecdat/ui/art_static.py +65 -0
- ecdat/ui/art_text.py +81 -0
- ecdat/ui/banner.py +148 -0
- ecdat/ui/console.py +119 -0
- ecdat/ui/motion.py +64 -0
- ecdat/ui/render.py +486 -0
- ecdat/ui/theme.py +173 -0
- ecdat-0.2.0.dist-info/METADATA +142 -0
- ecdat-0.2.0.dist-info/RECORD +51 -0
- ecdat-0.2.0.dist-info/WHEEL +5 -0
- ecdat-0.2.0.dist-info/entry_points.txt +2 -0
- ecdat-0.2.0.dist-info/licenses/LICENSE +21 -0
- ecdat-0.2.0.dist-info/top_level.txt +2 -0
- ecdat_core/__init__.py +6 -0
- ecdat_core/cbom_export.py +287 -0
- ecdat_core/cli.py +202 -0
- ecdat_core/detector.py +273 -0
- ecdat_core/ingestion.py +581 -0
- ecdat_core/models.py +145 -0
- ecdat_core/recommender.py +74 -0
- ecdat_core/risk_engine.py +264 -0
- ecdat_core/signature_loader.py +204 -0
- ecdat_core/signatures.json +692 -0
ecdat/ui/art3d.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Pure ASCII 3D renderers — deterministic, dependency-free, unit-testable."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
Cell = tuple[str, int] # (character, level) — level 0 means "empty"
|
|
7
|
+
Frame = list[list[Cell]] # frame[row][col]
|
|
8
|
+
|
|
9
|
+
BLANK: Cell = (" ", 0)
|
|
10
|
+
TORUS_CHARS = ".,-~:;=!*#$@" # 12 shades, dark -> bright
|
|
11
|
+
TORUS_LEVELS = len(TORUS_CHARS)
|
|
12
|
+
GLOBE_LEVELS = 12
|
|
13
|
+
HEX = "0123456789ABCDEF"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def blank_frame(width: int, height: int) -> Frame:
|
|
17
|
+
return [[BLANK] * max(width, 0) for _ in range(max(height, 0))]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def frame_to_rows(frame: Frame) -> list[str]:
|
|
21
|
+
return ["".join(ch for ch, _ in row) for row in frame]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def render_torus(a: float, b: float, width: int, height: int, *,
|
|
25
|
+
step_theta: float = 0.07, step_phi: float = 0.04) -> Frame:
|
|
26
|
+
"""Classic spinning donut. a, b are rotation angles in radians."""
|
|
27
|
+
frame = blank_frame(width, height)
|
|
28
|
+
if width < 8 or height < 4:
|
|
29
|
+
return frame
|
|
30
|
+
r1, r2, k2 = 1.0, 2.0, 5.0
|
|
31
|
+
k1 = min(width, 2 * height) * k2 * 3.0 / (8.0 * (r1 + r2))
|
|
32
|
+
zbuf = [[0.0] * width for _ in range(height)]
|
|
33
|
+
ca, sa, cb, sb = math.cos(a), math.sin(a), math.cos(b), math.sin(b)
|
|
34
|
+
two_pi = 2.0 * math.pi
|
|
35
|
+
theta = 0.0
|
|
36
|
+
while theta < two_pi:
|
|
37
|
+
ct, st = math.cos(theta), math.sin(theta)
|
|
38
|
+
circle_x, circle_y = r2 + r1 * ct, r1 * st
|
|
39
|
+
phi = 0.0
|
|
40
|
+
while phi < two_pi:
|
|
41
|
+
cp, sp = math.cos(phi), math.sin(phi)
|
|
42
|
+
x = circle_x * (cb * cp + sa * sb * sp) - circle_y * ca * sb
|
|
43
|
+
y = circle_x * (sb * cp - sa * cb * sp) + circle_y * ca * cb
|
|
44
|
+
z = k2 + ca * circle_x * sp + circle_y * sa
|
|
45
|
+
ooz = 1.0 / z
|
|
46
|
+
xp = int(width / 2 + k1 * ooz * x)
|
|
47
|
+
yp = int(height / 2 - k1 * ooz * y * 0.5) # 0.5 = terminal cell aspect ratio
|
|
48
|
+
if 0 <= xp < width and 0 <= yp < height and ooz > zbuf[yp][xp]:
|
|
49
|
+
lum = cp * ct * sb - ca * ct * sp - sa * st + cb * (ca * st - ct * sa * sp)
|
|
50
|
+
if lum > 0:
|
|
51
|
+
idx = min(int(lum * 8), TORUS_LEVELS - 1)
|
|
52
|
+
zbuf[yp][xp] = ooz
|
|
53
|
+
frame[yp][xp] = (TORUS_CHARS[idx], idx + 1)
|
|
54
|
+
phi += step_phi
|
|
55
|
+
theta += step_theta
|
|
56
|
+
return frame
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _star(col: int, row: int, t: float) -> Cell | None:
|
|
60
|
+
h = (col * 73856093) ^ (row * 19349663)
|
|
61
|
+
if h % 61 != 0:
|
|
62
|
+
return None
|
|
63
|
+
phase = (int(t * 2.5) + (h >> 7)) % 3
|
|
64
|
+
return (".", 1) if phase == 0 else (("+", 2) if phase == 1 else ("'", 1))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def render_globe(t: float, width: int, height: int, *, ring: bool = True, stars: bool = True) -> Frame:
|
|
68
|
+
"""A sphere whose surface is hex digits glued to it (they rotate with t), lit from the upper-left,
|
|
69
|
+
with a lat/long grid, an orbit ring precessing around it and three satellites."""
|
|
70
|
+
frame = blank_frame(width, height)
|
|
71
|
+
if width < 16 or height < 8:
|
|
72
|
+
return frame
|
|
73
|
+
cx, cy = (width - 1) / 2.0, (height - 1) / 2.0
|
|
74
|
+
rx = min(width / 2.8, height * 0.85) # sphere radius in x-cells; y radius = rx / 2
|
|
75
|
+
lx, ly, lz = -0.45, 0.55, 0.70
|
|
76
|
+
n = math.sqrt(lx * lx + ly * ly + lz * lz)
|
|
77
|
+
lx, ly, lz = lx / n, ly / n, lz / n
|
|
78
|
+
ct, st = math.cos(t), math.sin(t)
|
|
79
|
+
two_pi = 2.0 * math.pi
|
|
80
|
+
for row in range(height):
|
|
81
|
+
ny = -(row - cy) * 2.0 / rx
|
|
82
|
+
for col in range(width):
|
|
83
|
+
nx = (col - cx) / rx
|
|
84
|
+
d2 = nx * nx + ny * ny
|
|
85
|
+
if d2 > 1.0:
|
|
86
|
+
if stars:
|
|
87
|
+
s = _star(col, row, t)
|
|
88
|
+
if s:
|
|
89
|
+
frame[row][col] = s
|
|
90
|
+
continue
|
|
91
|
+
nz = math.sqrt(1.0 - d2)
|
|
92
|
+
shade = 0.12 + 0.78 * max(0.0, nx * lx + ny * ly + nz * lz) + 0.25 * (1.0 - nz) ** 2
|
|
93
|
+
level = max(1, min(GLOBE_LEVELS, 1 + int(shade * (GLOBE_LEVELS - 1))))
|
|
94
|
+
sx = nx * ct - nz * st
|
|
95
|
+
sz = nx * st + nz * ct
|
|
96
|
+
lon = math.atan2(sx, sz)
|
|
97
|
+
lat = math.asin(max(-1.0, min(1.0, ny)))
|
|
98
|
+
qi = int((lon + math.pi) / two_pi * 96) % 96
|
|
99
|
+
qj = min(int((lat + math.pi / 2) / math.pi * 48), 47)
|
|
100
|
+
grid = qi % 12 == 0 or qj % 8 == 0
|
|
101
|
+
if level <= 2:
|
|
102
|
+
ch = "."
|
|
103
|
+
elif level <= 4:
|
|
104
|
+
ch = "+" if grid else ":"
|
|
105
|
+
else:
|
|
106
|
+
ch = HEX[(((qi * 73856093) ^ (qj * 19349663)) >> 3) % 16]
|
|
107
|
+
frame[row][col] = (ch, min(GLOBE_LEVELS, level + (1 if grid else 0)))
|
|
108
|
+
if ring:
|
|
109
|
+
cr, sr = math.cos(0.42), math.sin(0.42) # ring tilt about X
|
|
110
|
+
yaw = t * 1.7 + 0.6
|
|
111
|
+
cyw, syw = math.cos(yaw), math.sin(yaw) # ring precession about Y
|
|
112
|
+
rr = 1.28 * rx
|
|
113
|
+
|
|
114
|
+
def project(phi: float) -> tuple[int, int, bool]:
|
|
115
|
+
px, py, pz = math.cos(phi), 0.0, math.sin(phi)
|
|
116
|
+
py, pz = py * cr - pz * sr, py * sr + pz * cr
|
|
117
|
+
px, pz = px * cyw + pz * syw, -px * syw + pz * cyw
|
|
118
|
+
return int(round(cx + px * rr)), int(round(cy - py * rr / 2.0)), pz > 0
|
|
119
|
+
|
|
120
|
+
def visible(col: int, row: int, front: bool) -> bool:
|
|
121
|
+
if not (0 <= col < width and 0 <= row < height):
|
|
122
|
+
return False
|
|
123
|
+
if front:
|
|
124
|
+
return True
|
|
125
|
+
dx, dy = (col - cx) / rx, (row - cy) * 2.0 / rx
|
|
126
|
+
return dx * dx + dy * dy > 1.0 # behind the globe: hidden where the disc covers it
|
|
127
|
+
|
|
128
|
+
for i in range(240):
|
|
129
|
+
col, row, front = project(two_pi * i / 240)
|
|
130
|
+
if visible(col, row, front):
|
|
131
|
+
frame[row][col] = ("o", GLOBE_LEVELS) if front else (".", 5)
|
|
132
|
+
for k in range(3): # satellites
|
|
133
|
+
col, row, front = project(t * 2.2 + k * 2.0944)
|
|
134
|
+
if visible(col, row, front):
|
|
135
|
+
frame[row][col] = ("@", GLOBE_LEVELS)
|
|
136
|
+
return frame
|
ecdat/ui/art_static.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Static ASCII art for the results screens (pure, dependency-free). Every emblem is 9 rows x 19 cols."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
|
|
6
|
+
SAFE_LOCK = (
|
|
7
|
+
r' .-"""-. ',
|
|
8
|
+
r' / .-. \ ',
|
|
9
|
+
r' | | | | ',
|
|
10
|
+
r" .-'-'---'-'-. ",
|
|
11
|
+
r' | .---. | ',
|
|
12
|
+
r' | | o | | ',
|
|
13
|
+
r" | '-.-' | ",
|
|
14
|
+
r' | | | ',
|
|
15
|
+
r" '-----------' ",
|
|
16
|
+
)
|
|
17
|
+
WARN_LOCK = (
|
|
18
|
+
r' .-"""-. ',
|
|
19
|
+
r' / .-. \ ',
|
|
20
|
+
r' | | | | ',
|
|
21
|
+
r" .-'-'---'-'-. ",
|
|
22
|
+
r' | .-\-. | ',
|
|
23
|
+
r' | | ! | | ',
|
|
24
|
+
r" | '-/-' | ",
|
|
25
|
+
r' | | | ',
|
|
26
|
+
r" '-----------' ",
|
|
27
|
+
)
|
|
28
|
+
CRIT_LOCK = (
|
|
29
|
+
r' .-"""-. ',
|
|
30
|
+
r' / .-. \ ',
|
|
31
|
+
r' | | | ',
|
|
32
|
+
r" .-'-'---'-'-. ",
|
|
33
|
+
r' | .---. | ',
|
|
34
|
+
r' | | X | | ',
|
|
35
|
+
r" | '-.-' | ",
|
|
36
|
+
r' | | | ',
|
|
37
|
+
r" '-----------' ",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def verdict(counts: Mapping[str, int]) -> str:
|
|
42
|
+
"""'crit' | 'warn' | 'safe' | 'empty' from a risk-level → count mapping."""
|
|
43
|
+
total = sum(counts.values())
|
|
44
|
+
if total == 0:
|
|
45
|
+
return "empty"
|
|
46
|
+
if counts.get("critical", 0) >= 1:
|
|
47
|
+
return "crit"
|
|
48
|
+
if counts.get("high", 0) + counts.get("medium", 0) >= 1:
|
|
49
|
+
return "warn"
|
|
50
|
+
return "safe"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def emblem_for(v: str) -> tuple[str, ...]:
|
|
54
|
+
return {"crit": CRIT_LOCK, "warn": WARN_LOCK}.get(v, SAFE_LOCK)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def headline_for(counts: Mapping[str, int], files: int = 0) -> str:
|
|
58
|
+
v = verdict(counts)
|
|
59
|
+
if v == "crit":
|
|
60
|
+
return f"{counts.get('critical', 0)} CRITICAL · {counts.get('high', 0)} HIGH — migrate before your next review"
|
|
61
|
+
if v == "warn":
|
|
62
|
+
return f"{counts.get('high', 0)} HIGH · {counts.get('medium', 0)} MEDIUM — plan your PQC migration"
|
|
63
|
+
if v == "safe":
|
|
64
|
+
return "Looking good — no urgent quantum exposure"
|
|
65
|
+
return f"No cryptographic artefacts detected in {files} files"
|
ecdat/ui/art_text.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Convert 3D frame data into gradient-coloured Rich Text.
|
|
2
|
+
|
|
3
|
+
Consumes :class:`Frame` from :mod:`ecdat.ui.art3d` and produces
|
|
4
|
+
:class:`~rich.text.Text` objects suitable for rendering.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Sequence
|
|
10
|
+
|
|
11
|
+
from rich.style import Style
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from ecdat.ui.theme import MINT_GRADIENT
|
|
15
|
+
|
|
16
|
+
Frame = Sequence[Sequence[tuple[str, int]]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def frame_to_text(
|
|
20
|
+
frame: Frame,
|
|
21
|
+
gradient: list[str] | None = None,
|
|
22
|
+
) -> Text:
|
|
23
|
+
"""Convert a :class:`Frame` into gradient-coloured :class:`~rich.text.Text`.
|
|
24
|
+
|
|
25
|
+
Level 0 cells map to a space with no style. Level *n* (≥ 1) maps to
|
|
26
|
+
``gradient[min(n, len(gradient)) - 1]``. Consecutive cells at the same
|
|
27
|
+
level are merged into a single :class:`~rich.text.Text` run to keep the
|
|
28
|
+
output compact.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
frame: A sequence of rows, each a sequence of ``(char, level)`` tuples.
|
|
32
|
+
gradient: Colour gradient (12 hex colours by default);
|
|
33
|
+
defaults to :data:`~ecdat.ui.theme.MINT_GRADIENT`.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
A :class:`~rich.text.Text` ready for ``console.print()``.
|
|
37
|
+
"""
|
|
38
|
+
if gradient is None:
|
|
39
|
+
gradient = MINT_GRADIENT
|
|
40
|
+
n_colors = len(gradient)
|
|
41
|
+
|
|
42
|
+
text = Text()
|
|
43
|
+
for ri, row in enumerate(frame):
|
|
44
|
+
if ri > 0:
|
|
45
|
+
text.append("\n")
|
|
46
|
+
if not row:
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
# Run-length encode consecutive same-level cells.
|
|
50
|
+
prev_level = -1 # sentinel
|
|
51
|
+
run_chars: list[str] = []
|
|
52
|
+
for ch, level in row:
|
|
53
|
+
if level == prev_level:
|
|
54
|
+
run_chars.append(ch)
|
|
55
|
+
continue
|
|
56
|
+
# Flush previous run.
|
|
57
|
+
_flush_run(text, run_chars, prev_level, gradient, n_colors)
|
|
58
|
+
run_chars = [ch]
|
|
59
|
+
prev_level = level
|
|
60
|
+
_flush_run(text, run_chars, prev_level, gradient, n_colors)
|
|
61
|
+
|
|
62
|
+
return text
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _flush_run(
|
|
66
|
+
text: Text,
|
|
67
|
+
chars: list[str],
|
|
68
|
+
level: int,
|
|
69
|
+
gradient: list[str],
|
|
70
|
+
n_colors: int,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Append a run of same-level characters to *text*."""
|
|
73
|
+
if not chars:
|
|
74
|
+
return
|
|
75
|
+
run_str = "".join(chars)
|
|
76
|
+
chars.clear()
|
|
77
|
+
if level <= 0:
|
|
78
|
+
text.append(run_str) # blank — no style
|
|
79
|
+
return
|
|
80
|
+
color_idx = min(level, n_colors) - 1
|
|
81
|
+
text.append(run_str, style=Style(color=gradient[color_idx]))
|
ecdat/ui/banner.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
r"""Gradient ASCII banner with optional shimmer highlight.
|
|
2
|
+
|
|
3
|
+
The banner uses big Unicode box-drawing characters for modern terminals and
|
|
4
|
+
falls back to pure ASCII for legacy environments. Each column is coloured
|
|
5
|
+
with a :data:`~ecdat.ui.theme.MINT_GRADIENT` gradient.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from rich.console import Group, RenderableType
|
|
13
|
+
from rich.style import Style
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from ecdat.ui.theme import MINT_GRADIENT, PALETTE
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Banner ASCII art — raw strings for backslash safety
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
BANNER_LINES = (
|
|
23
|
+
r"███████╗ ██████╗██████╗ █████╗ ████████╗",
|
|
24
|
+
r"██╔════╝██╔════╝██╔══██╗██╔══██╗╚══██╔══╝",
|
|
25
|
+
r"█████╗ ██║ ██║ ██║███████║ ██║ ",
|
|
26
|
+
r"██╔══╝ ██║ ██║ ██║██╔══██║ ██║ ",
|
|
27
|
+
r"███████╗╚██████╗██████╔╝██║ ██║ ██║ ",
|
|
28
|
+
r"╚══════╝ ╚═════╝╚═════╝ ╚═╝ ╚═╝ ╚═╝ ",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
BANNER_ASCII_LINES = (
|
|
32
|
+
r" _____ ____ ____ _ _____ ",
|
|
33
|
+
r"| ____| / ___|| _ \ / \ |_ _|",
|
|
34
|
+
r"| _| | | | | | | / _ \ | | ",
|
|
35
|
+
r"| |___ | |___ | |_| | / ___ \ | | ",
|
|
36
|
+
r"|_____| \____||____/ /_/ \_\ |_| ",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
TAGLINE = "Enterprise Cryptographic Discovery & Analysis Tool"
|
|
40
|
+
SUBLINE = "post-quantum readiness \xb7 CBOM \xb7 Mosca"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Public API
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def banner_text(
|
|
49
|
+
*,
|
|
50
|
+
unicode: bool = True,
|
|
51
|
+
shimmer: Optional[float] = None,
|
|
52
|
+
) -> Text:
|
|
53
|
+
"""Return the ECDAT banner as a gradient-coloured :class:`~rich.text.Text`.
|
|
54
|
+
|
|
55
|
+
Each column is coloured from :data:`~ecdat.ui.theme.MINT_GRADIENT`, cycling
|
|
56
|
+
left-to-right across the full width of the banner text.
|
|
57
|
+
|
|
58
|
+
When *shimmer* is a float in ``[0, 1]``, the gradient is locally brightened
|
|
59
|
+
in a window of ±3 columns around ``shimmer * width`` — this produces a
|
|
60
|
+
"scanning highlight" effect when animated across frames.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
unicode: Use the full box-drawing banner (``True``) or pure ASCII
|
|
64
|
+
fallback (``False``).
|
|
65
|
+
shimmer: Optional highlight position ``0..1``; ``None`` disables.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
A single :class:`~rich.text.Text` with styled characters.
|
|
69
|
+
"""
|
|
70
|
+
lines = BANNER_LINES if unicode else BANNER_ASCII_LINES
|
|
71
|
+
gradient = MINT_GRADIENT
|
|
72
|
+
n_colors = len(gradient)
|
|
73
|
+
|
|
74
|
+
# Determine max width for per-column gradient mapping.
|
|
75
|
+
max_width = max(len(line) for line in lines) if lines else 0
|
|
76
|
+
|
|
77
|
+
shimmer_col = -1
|
|
78
|
+
if shimmer is not None:
|
|
79
|
+
shimmer_col = int(round(max(0.0, min(1.0, shimmer)) * (max_width - 1)))
|
|
80
|
+
# Clamp shimmer window to valid column range.
|
|
81
|
+
shimmer_col = max(0, min(max_width - 1, shimmer_col))
|
|
82
|
+
|
|
83
|
+
text = Text()
|
|
84
|
+
for li, line in enumerate(lines):
|
|
85
|
+
if li > 0:
|
|
86
|
+
text.append("\n")
|
|
87
|
+
for ci, ch in enumerate(line):
|
|
88
|
+
# Base gradient index from column position.
|
|
89
|
+
if max_width > 1:
|
|
90
|
+
base_idx = ci * (n_colors - 1) // (max_width - 1)
|
|
91
|
+
else:
|
|
92
|
+
base_idx = 0
|
|
93
|
+
|
|
94
|
+
# Shimmer brightens ±3 columns around shimmer_col.
|
|
95
|
+
idx = base_idx
|
|
96
|
+
if shimmer is not None and abs(ci - shimmer_col) <= 3:
|
|
97
|
+
boost = max(0, 3 - abs(ci - shimmer_col))
|
|
98
|
+
idx = min(n_colors - 1, base_idx + boost)
|
|
99
|
+
|
|
100
|
+
color = gradient[idx]
|
|
101
|
+
text.append(ch, style=Style(color=color, bold=False))
|
|
102
|
+
|
|
103
|
+
return text
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def render_banner(
|
|
107
|
+
version: Optional[str] = None,
|
|
108
|
+
*,
|
|
109
|
+
unicode: bool = True,
|
|
110
|
+
) -> Group:
|
|
111
|
+
"""Return the full banner as a :class:`~rich.console.Group`.
|
|
112
|
+
|
|
113
|
+
Layout (centred)::
|
|
114
|
+
|
|
115
|
+
[gradient banner art]
|
|
116
|
+
TAGLINE
|
|
117
|
+
SUBLINE
|
|
118
|
+
version (e.g. "v0.2.0")
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
version: Optional version string to display below the subline.
|
|
122
|
+
unicode: Use box-drawing banner (``True``) or ASCII (``False``).
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
A :class:`~rich.console.Group` ready for ``console.print()``.
|
|
126
|
+
"""
|
|
127
|
+
elements: list[RenderableType] = []
|
|
128
|
+
|
|
129
|
+
# Banner art.
|
|
130
|
+
elements.append(banner_text(unicode=unicode))
|
|
131
|
+
|
|
132
|
+
# TAGLINE — centred, accent colour.
|
|
133
|
+
tagline = Text(TAGLINE, style=Style(color=PALETTE.accent, bold=True))
|
|
134
|
+
tagline.justify = "center"
|
|
135
|
+
elements.append(tagline)
|
|
136
|
+
|
|
137
|
+
# SUBLINE — centred, muted.
|
|
138
|
+
subline = Text(SUBLINE, style=Style(color=PALETTE.muted))
|
|
139
|
+
subline.justify = "center"
|
|
140
|
+
elements.append(subline)
|
|
141
|
+
|
|
142
|
+
# Version — centred, dimmed.
|
|
143
|
+
if version:
|
|
144
|
+
ver = Text(f"v{version}", style=Style(color=PALETTE.muted, dim=True))
|
|
145
|
+
ver.justify = "center"
|
|
146
|
+
elements.append(ver)
|
|
147
|
+
|
|
148
|
+
return Group(*elements)
|
ecdat/ui/console.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Console factory — the ONLY module allowed to create a Rich :class:`~rich.console.Console`.
|
|
2
|
+
|
|
3
|
+
Every :class:`~rich.console.Console` goes through :func:`make_console` so that
|
|
4
|
+
the palette theme, ``NO_COLOR``, ``FORCE_COLOR``, and terminal width are
|
|
5
|
+
applied consistently. Direct ``Console()`` calls elsewhere are banned.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.theme import Theme
|
|
16
|
+
|
|
17
|
+
from ecdat.ui.theme import rich_theme
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _ensure_utf8_streams() -> None:
|
|
21
|
+
"""Reconfigure stdout/stderr to UTF-8 when possible.
|
|
22
|
+
|
|
23
|
+
On Windows the default code page rarely supports box-drawing characters.
|
|
24
|
+
When a stream has a ``.reconfigure`` method (Python 3.7+) and is not
|
|
25
|
+
already UTF-8, we switch it to ``"utf-8"`` with ``errors="replace"`` so
|
|
26
|
+
that Rich can render box-drawing characters without raising
|
|
27
|
+
:exc:`UnicodeEncodeError`.
|
|
28
|
+
|
|
29
|
+
Never raises — the reconfigure is best-effort.
|
|
30
|
+
"""
|
|
31
|
+
for stream in (sys.stdout, sys.stderr):
|
|
32
|
+
try:
|
|
33
|
+
reconf = getattr(stream, "reconfigure", None)
|
|
34
|
+
if reconf is None:
|
|
35
|
+
continue
|
|
36
|
+
current_enc = getattr(stream, "encoding", None)
|
|
37
|
+
if current_enc is not None and current_enc.lower() not in (
|
|
38
|
+
"utf-8",
|
|
39
|
+
"utf8",
|
|
40
|
+
):
|
|
41
|
+
reconf(encoding="utf-8", errors="replace")
|
|
42
|
+
except Exception:
|
|
43
|
+
pass # best-effort
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def make_console(
|
|
47
|
+
*,
|
|
48
|
+
stderr: bool = False,
|
|
49
|
+
no_color: Optional[bool] = None,
|
|
50
|
+
width: Optional[int] = None,
|
|
51
|
+
) -> Console:
|
|
52
|
+
"""Create a themed :class:`~rich.console.Console`.
|
|
53
|
+
|
|
54
|
+
Callers outside ``ecdat/ui/`` use this factory; never construct a
|
|
55
|
+
``Console`` directly.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
stderr: Write to stderr instead of stdout (default: stdout).
|
|
59
|
+
no_color: Force colour off (``True``), on (``False``), or auto-detect
|
|
60
|
+
(``None`` / omitted). Respects the ``NO_COLOR`` and
|
|
61
|
+
``FORCE_COLOR`` env vars when *no_color* is ``None``.
|
|
62
|
+
width: Terminal width in cells; ``None`` auto-detects.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
A configured :class:`~rich.console.Console`.
|
|
66
|
+
"""
|
|
67
|
+
_ensure_utf8_streams()
|
|
68
|
+
|
|
69
|
+
# Resolve no_color: explicit arg > NO_COLOR/FORCE_COLOR > auto-detect.
|
|
70
|
+
if no_color is None:
|
|
71
|
+
force = os.environ.get("FORCE_COLOR", "").strip()
|
|
72
|
+
if force == "0" or os.environ.get("NO_COLOR", "").strip():
|
|
73
|
+
no_color = True
|
|
74
|
+
elif force and force != "0":
|
|
75
|
+
no_color = False
|
|
76
|
+
|
|
77
|
+
# Build colour system and no_color flags.
|
|
78
|
+
if no_color is True:
|
|
79
|
+
color_system = None
|
|
80
|
+
_no_color = True
|
|
81
|
+
elif no_color is False:
|
|
82
|
+
color_system = "256"
|
|
83
|
+
_no_color = False
|
|
84
|
+
else:
|
|
85
|
+
color_system = "auto"
|
|
86
|
+
_no_color = None # let Rich auto-detect
|
|
87
|
+
|
|
88
|
+
theme: Optional[Theme]
|
|
89
|
+
if _no_color is True:
|
|
90
|
+
theme = None
|
|
91
|
+
else:
|
|
92
|
+
theme = rich_theme()
|
|
93
|
+
|
|
94
|
+
return Console(
|
|
95
|
+
file=sys.stderr if stderr else sys.stdout,
|
|
96
|
+
theme=theme,
|
|
97
|
+
color_system=color_system,
|
|
98
|
+
no_color=_no_color,
|
|
99
|
+
width=width,
|
|
100
|
+
highlight=False,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def is_interactive() -> bool:
|
|
105
|
+
"""Return ``True`` when stdout is connected to an interactive terminal.
|
|
106
|
+
|
|
107
|
+
Delegates to Rich's :func:`~rich.console.detect_legacy_windows` and
|
|
108
|
+
``sys.stdout.isatty()``. Returns ``False`` in CI or when piped.
|
|
109
|
+
"""
|
|
110
|
+
return sys.stdout.isatty()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def supports_unicode(console: Console) -> bool:
|
|
114
|
+
"""Return ``True`` when the console's encoding supports Unicode box-drawing.
|
|
115
|
+
|
|
116
|
+
This is almost always ``True`` on modern terminals (including Windows
|
|
117
|
+
Terminal). Falls back to ``False`` for legacy Windows consoles.
|
|
118
|
+
"""
|
|
119
|
+
return console.legacy_windows is False or console.encoding == "utf-8"
|
ecdat/ui/motion.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Motion / animation policy — respects reduced-motion, CI, and env overrides.
|
|
2
|
+
|
|
3
|
+
Exports a single function: :func:`animations_enabled`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def animations_enabled(
|
|
13
|
+
reduce_motion: bool = False,
|
|
14
|
+
env: Optional[dict[str, str]] = None,
|
|
15
|
+
) -> bool:
|
|
16
|
+
"""Return ``True`` when animations should play.
|
|
17
|
+
|
|
18
|
+
Resolution order (first match wins):
|
|
19
|
+
|
|
20
|
+
1. ``ECDAT_ANIM`` env var — ``"0"``/``"off"``/``"false"``/``"no"`` →
|
|
21
|
+
``False``; ``"1"``/``"on"``/``"true"``/``"yes"`` → ``True``.
|
|
22
|
+
2. If *reduce_motion* is ``True`` → ``False``.
|
|
23
|
+
3. If running under CI (``CI`` env var set to anything truthy) → ``False``.
|
|
24
|
+
4. If running under pytest (``PYTEST_CURRENT_TEST`` set) → ``False``.
|
|
25
|
+
5. If ``TERM`` is ``"dumb"`` → ``False``.
|
|
26
|
+
6. Otherwise → ``True`` (default: animations on).
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
reduce_motion: Caller-level reduced-motion flag (e.g. from
|
|
30
|
+
``prefers-reduced-motion``).
|
|
31
|
+
env: Environment dict; defaults to :data:`os.environ`.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
``True`` if animations should play.
|
|
35
|
+
"""
|
|
36
|
+
if env is None:
|
|
37
|
+
env = os.environ
|
|
38
|
+
|
|
39
|
+
# 1. Explicit override via ECDAT_ANIM.
|
|
40
|
+
anim = env.get("ECDAT_ANIM", "").strip().lower()
|
|
41
|
+
if anim:
|
|
42
|
+
if anim in ("0", "off", "false", "no"):
|
|
43
|
+
return False
|
|
44
|
+
if anim in ("1", "on", "true", "yes"):
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
# 2. Reduced-motion flag.
|
|
48
|
+
if reduce_motion:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
# 3. CI environment.
|
|
52
|
+
if env.get("CI", "").strip():
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
# 4. Pytest.
|
|
56
|
+
if env.get("PYTEST_CURRENT_TEST", "").strip():
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
# 5. Dumb terminal.
|
|
60
|
+
if env.get("TERM", "").strip().lower() == "dumb":
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
# 6. Default: animations on.
|
|
64
|
+
return True
|