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/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